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 d3f3cd444..69c7eca20 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -510,64 +510,64 @@ public class SubscriptionController { .thenApply(this::requireRecordFromGetResult) .thenCompose(record -> { - final ProcessorCustomer processorCustomer = record.getProcessorCustomer() - .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)); + final ProcessorCustomer processorCustomer = record.getProcessorCustomer() + .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)); - final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, processorCustomer.processor()); + final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, + processorCustomer.processor()); - final SubscriptionProcessorManager manager = getManagerForProcessor(processorCustomer.processor()); + final SubscriptionProcessorManager manager = getManagerForProcessor(processorCustomer.processor()); - return Optional.ofNullable(record.subscriptionId) - .map(subId -> { - // we already have a subscription in our records so let's check the level and change it if needed - return manager.getSubscription(subId).thenCompose( - subscription -> manager.getLevelForSubscription(subscription).thenCompose(existingLevel -> { - if (level == existingLevel) { - return CompletableFuture.completedFuture(subscription); - } - return manager.updateSubscription( - subscription, subscriptionTemplateId, level, idempotencyKey) - .thenCompose(updatedSubscription -> - subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, - requestData.now, - level, updatedSubscription.id()) - .thenApply(unused -> updatedSubscription)); - })); - }).orElseGet(() -> { - long lastSubscriptionCreatedAt = - record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0; - - // we don't have a subscription yet so create it and then record the subscription id - // - // this relies on stripe's idempotency key to avoid creating more than one subscription if the client - // retries this request - return manager.createSubscription(processorCustomer.customerId(), - subscriptionTemplateId, - level, - lastSubscriptionCreatedAt) - .exceptionally(e -> { - if (e.getCause() instanceof StripeException stripeException - && stripeException.getCode().equals("subscription_payment_intent_requires_action")) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null - ) - ))).build()); + return Optional.ofNullable(record.subscriptionId).map(subId -> { + // we already have a subscription in our records so let's check the level and currency, + // and only change it if needed + return manager.getSubscription(subId).thenCompose( + subscription -> manager.getLevelAndCurrencyForSubscription(subscription) + .thenCompose(existingLevelAndCurrency -> { + if (existingLevelAndCurrency.equals(new SubscriptionProcessorManager.LevelAndCurrency(level, + currency.toLowerCase(Locale.ROOT)))) { + return CompletableFuture.completedFuture(subscription); } - if (e instanceof RuntimeException re) { - throw re; - } + return manager.updateSubscription( + subscription, subscriptionTemplateId, level, idempotencyKey) + .thenCompose(updatedSubscription -> + subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, + requestData.now, + level, updatedSubscription.id()) + .thenApply(unused -> updatedSubscription)); + })); + }).orElseGet(() -> { + long lastSubscriptionCreatedAt = + record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0; - throw new CompletionException(e); - }) - .thenCompose(subscription -> subscriptionManager.subscriptionCreated( - requestData.subscriberUser, subscription.id(), requestData.now, level) - .thenApply(unused -> subscription)); - }); + // we don't have a subscription yet so create it and then record the subscription id + return manager.createSubscription(processorCustomer.customerId(), + subscriptionTemplateId, + level, + lastSubscriptionCreatedAt) + .exceptionally(e -> { + if (e.getCause() instanceof StripeException stripeException + && stripeException.getCode().equals("subscription_payment_intent_requires_action")) { + throw new BadRequestException(Response.status(Status.BAD_REQUEST) + .entity(new SetSubscriptionLevelErrorResponse(List.of( + new SetSubscriptionLevelErrorResponse.Error( + SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null + ) + ))).build()); + } + if (e instanceof RuntimeException re) { + throw re; + } + + throw new CompletionException(e); + }) + .thenCompose(subscription -> subscriptionManager.subscriptionCreated( + requestData.subscriberUser, subscription.id(), requestData.now, level) + .thenApply(unused -> subscription)); + }); }) .thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build()); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index d53b219c1..939534eca 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -358,11 +358,13 @@ public class BraintreeManager implements SubscriptionProcessorManager { } @Override - public CompletableFuture getLevelForSubscription(Object subscriptionObj) { + public CompletableFuture getLevelAndCurrencyForSubscription(Object subscriptionObj) { final Subscription subscription = getSubscription(subscriptionObj); return findPlan(subscription.getPlanId()) - .thenApply(this::getLevelForPlan); + .thenApply( + plan -> new LevelAndCurrency(getLevelForPlan(plan), plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT))); + } private CompletableFuture findPlan(String planId) { 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 31730ddae..607f0ec78 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -246,37 +246,37 @@ public class StripeManager implements SubscriptionProcessorManager { @Override public CompletableFuture createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) { + // this relies on Stripe's idempotency key to avoid creating more than one subscription if the client + // retries this request return CompletableFuture.supplyAsync(() -> { SubscriptionCreateParams params = SubscriptionCreateParams.builder() .setCustomer(customerId) .setOffSession(true) .setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) .addItem(SubscriptionCreateParams.Item.builder() - .setPrice(priceId) - .build()) - .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) - .build(); - try { - // the idempotency key intentionally excludes priceId - // - // If the client tells the server several times in a row before the initial creation of a subscription to - // create a subscription, we want to ensure only one gets created. - return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( - customerId, lastSubscriptionCreatedAt))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(subscription -> new SubscriptionId(subscription.getId())); + .setPrice(priceId) + .build()) + .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) + .build(); + try { + // the idempotency key intentionally excludes priceId + // + // If the client tells the server several times in a row before the initial creation of a subscription to + // create a subscription, we want to ensure only one gets created. + return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( + customerId, lastSubscriptionCreatedAt))); + } catch (StripeException e) { + throw new CompletionException(e); + } + }, executor) + .thenApply(subscription -> new SubscriptionId(subscription.getId())); } @Override public CompletableFuture updateSubscription( Object subscriptionObj, String priceId, long level, String idempotencyKey) { - if (!(subscriptionObj instanceof final Subscription subscription)) { - throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); - } + final Subscription subscription = getSubscription(subscriptionObj); return CompletableFuture.supplyAsync(() -> { List items = new ArrayList<>(); @@ -400,12 +400,12 @@ public class StripeManager implements SubscriptionProcessorManager { } @Override - public CompletableFuture getLevelForSubscription(Object subscriptionObj) { - if (!(subscriptionObj instanceof final Subscription subscription)) { + public CompletableFuture getLevelAndCurrencyForSubscription(Object subscriptionObj) { + final Subscription subscription = getSubscription(subscriptionObj); - throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName()); - } - return getProductForSubscription(subscription).thenApply(this::getLevelForProduct); + return getProductForSubscription(subscription).thenApply( + product -> new LevelAndCurrency(getLevelForProduct(product), subscription.getCurrency().toLowerCase( + Locale.ROOT))); } public CompletableFuture getLevelForPrice(Price price) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java index 71fd6f91d..12c28a284 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -42,12 +42,16 @@ public interface SubscriptionProcessorManager { CompletableFuture getSubscription(String subscriptionId); CompletableFuture createSubscription(String customerId, String templateId, long level, - long lastSubscriptionCreatedAt); + long lastSubscriptionCreatedAt); CompletableFuture updateSubscription( Object subscription, String templateId, long level, String idempotencyKey); - CompletableFuture getLevelForSubscription(Object subscription); + /** + * @param subscription + * @return the subscription’s current level and lower-case currency code + */ + CompletableFuture getLevelAndCurrencyForSubscription(Object subscription); CompletableFuture cancelAllActiveSubscriptions(String customerId); @@ -160,4 +164,8 @@ public interface SubscriptionProcessorManager { } + record LevelAndCurrency(long level, String currency) { + + } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index b14405b90..d51a2580b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -72,7 +72,6 @@ import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.SystemMapper; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -548,7 +547,8 @@ class SubscriptionControllerTest { when(BRAINTREE_MANAGER.getSubscription(any())) .thenReturn(CompletableFuture.completedFuture(subscriptionObj)); when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) - .thenReturn(CompletableFuture.completedFuture(new Pair<>(existingLevel, existingCurrency))); + .thenReturn(CompletableFuture.completedFuture( + new SubscriptionProcessorManager.LevelAndCurrency(existingLevel, existingCurrency))); final String updatedSubscriptionId = "updatedSubscriptionId"; if (expectUpdate) {