diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index bcaa2a5a6..617eced01 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -435,16 +435,7 @@ public class SubscriptionController { final SubscriptionProcessorManager manager = getManagerForProcessor(processor); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> record.getProcessorCustomer() - .map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), - paymentMethodToken, record.subscriptionId)) - .orElseThrow(() -> - // a missing customer ID indicates the client made requests out of order, - // and needs to call create_payment_method to create a customer for the given payment method - new ClientErrorException(Status.CONFLICT))) - .thenApply(customer -> Response.ok().build()); + return setDefaultPaymentMethod(manager, paymentMethodToken, requestData); } public record SetSubscriptionLevelSuccessResponse(long level) { @@ -987,6 +978,33 @@ public class SubscriptionController { }); } + @POST + @Path("/{subscriberId}/default_payment_method_for_ideal/{setupIntentId}") + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture setDefaultPaymentMethodForIdeal( + @Auth Optional authenticatedAccount, + @PathParam("subscriberId") String subscriberId, + @PathParam("setupIntentId") @NotEmpty String setupIntentId) { + RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); + + return stripeManager.getGeneratedSepaIdFromSetupIntent(setupIntentId) + .thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, requestData)); + } + + private CompletableFuture setDefaultPaymentMethod(final SubscriptionProcessorManager manager, + final String paymentMethodId, + final RequestData requestData) { + return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) + .thenApply(this::requireRecordFromGetResult) + .thenCompose(record -> record.getProcessorCustomer() + .map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), + paymentMethodId, record.subscriptionId)) + .orElseThrow(() -> + // a missing customer ID indicates the client made requests out of order, + // and needs to call create_payment_method to create a customer for the given payment method + new ClientErrorException(Status.CONFLICT))) + .thenApply(customer -> Response.ok().build()); + } private Instant receiptExpirationWithGracePeriod(Instant itemExpiration) { return itemExpiration.plus(subscriptionConfiguration.getBadgeGracePeriod()) .truncatedTo(ChronoUnit.DAYS) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index a988a4601..6e24b13f8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -31,6 +31,7 @@ import com.stripe.param.PaymentIntentCreateParams; import com.stripe.param.PaymentIntentRetrieveParams; import com.stripe.param.PriceRetrieveParams; import com.stripe.param.SetupIntentCreateParams; +import com.stripe.param.SetupIntentRetrieveParams; import com.stripe.param.SubscriptionCancelParams; import com.stripe.param.SubscriptionCreateParams; import com.stripe.param.SubscriptionItemListParams; @@ -63,7 +64,9 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import javax.ws.rs.ClientErrorException; import javax.ws.rs.InternalServerErrorException; +import javax.ws.rs.NotFoundException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; @@ -632,6 +635,33 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } + public CompletableFuture getGeneratedSepaIdFromSetupIntent(String setupIntentId) { + return CompletableFuture.supplyAsync(() -> { + SetupIntentRetrieveParams params = SetupIntentRetrieveParams.builder() + .addExpand("latest_attempt") + .build(); + try { + final SetupIntent setupIntent = stripeClient.setupIntents().retrieve(setupIntentId, params, commonOptions()); + if (setupIntent.getLatestAttemptObject() == null + || setupIntent.getLatestAttemptObject().getPaymentMethodDetails() == null + || setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal() == null + || setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit() == null) { + // This usually indicates that the client has made requests out of order, either by not confirming + // the SetupIntent or not having the user authorize the transaction. + logger.debug("setupIntent {} missing expected fields", setupIntentId); + throw new ClientErrorException(Status.CONFLICT); + } + return setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit(); + } catch (StripeException e) { + if (e.getStatusCode() == 404) { + throw new NotFoundException(); + } + logger.error("unexpected error from Stripe when retrieving setupIntent {}", setupIntentId, e); + throw new CompletionException(e); + } + }, executor); + } + /** * We use a client generated idempotency key for subscription updates due to not being able to distinguish between a * call to update to level 2, then back to level 1, then back to level 2. If this all happens within Stripe's