diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 74b63be42..a230bcefe 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -1142,7 +1142,7 @@ public class WhisperServerService extends Application setAppStoreSubscription( + @ReadOnly @Auth Optional authenticatedAccount, + @PathParam("subscriberId") String subscriberId, + @PathParam("originalTransactionId") String originalTransactionId) throws SubscriptionException { + final SubscriberCredentials subscriberCredentials = + SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); + + return subscriptionManager + .updateAppStoreTransactionId(subscriberCredentials, appleAppStoreManager, originalTransactionId) + .thenApply(SetSubscriptionLevelSuccessResponse::new); + } + @POST @Path("/{subscriberId}/playbilling/{purchaseToken}") diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java index 328f8e2a1..dcc88b98c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java @@ -22,6 +22,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.controllers.SubscriptionController; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; @@ -116,7 +117,8 @@ public class SubscriptionManager { .thenRun(Util.NOOP); } - public CompletableFuture> getSubscriptionInformation(final SubscriberCredentials subscriberCredentials) { + public CompletableFuture> getSubscriptionInformation( + final SubscriberCredentials subscriberCredentials) { return getSubscriber(subscriberCredentials).thenCompose(record -> { if (record.subscriptionId == null) { return CompletableFuture.completedFuture(Optional.empty()); @@ -155,8 +157,8 @@ public class SubscriptionManager { * * @param subscriberCredentials Subscriber credentials derived from the subscriberId * @param request The ZK Receipt credential request - * @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} and returns - * the expiration time of the receipt + * @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} + * and returns the expiration time of the receipt * @return If the subscription had a valid payment, the requested ZK receipt credential */ public CompletableFuture createReceiptCredentials( @@ -300,8 +302,9 @@ public class SubscriptionManager { .getSubscription(subId) .thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription) .thenCompose(existingLevelAndCurrency -> { - if (existingLevelAndCurrency.equals(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level, - currency.toLowerCase(Locale.ROOT)))) { + if (existingLevelAndCurrency.equals( + new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level, + currency.toLowerCase(Locale.ROOT)))) { return CompletableFuture.completedFuture(null); } if (!transitionValidator.isTransitionValid(existingLevelAndCurrency.level(), level)) { @@ -387,6 +390,41 @@ public class SubscriptionManager { .thenApply(ignore -> validatedToken.getLevel()))); } + /** + * Check the provided app store transactionId and write it the subscriptions table if is valid. + * + * @param subscriberCredentials Subscriber credentials derived from the subscriberId + * @param appleAppStoreManager Performs app store API operations + * @param originalTransactionId The client provided originalTransactionId that represents a purchased subscription in + * the app store + * @return A stage that completes with the subscription level for the accepted subscription + */ + public CompletableFuture updateAppStoreTransactionId( + final SubscriberCredentials subscriberCredentials, + final AppleAppStoreManager appleAppStoreManager, + final String originalTransactionId) { + + return getSubscriber(subscriberCredentials).thenCompose(record -> { + if (record.processorCustomer != null + && record.processorCustomer.processor() != PaymentProvider.APPLE_APP_STORE) { + return CompletableFuture.failedFuture( + new SubscriptionException.ProcessorConflict("existing processor does not match")); + } + + // For IAP providers, the subscriptionId and the customerId are both just the identifier for the subscription in + // the provider (in this case, the originalTransactionId). Changes to the subscription always just result in a new + // originalTransactionId + final ProcessorCustomer pc = new ProcessorCustomer(originalTransactionId, PaymentProvider.APPLE_APP_STORE); + + return appleAppStoreManager + .validateTransaction(originalTransactionId) + .thenCompose(level -> subscriptions + .setIapPurchase(record, pc, originalTransactionId, level, subscriberCredentials.now()) + .thenApply(ignore -> level)); + }); + + } + private SubscriptionPaymentProcessor getProcessor(PaymentProvider provider) { return processors.get(provider); } 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 2ff8b3664..06ad15174 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -82,6 +82,7 @@ import org.whispersystems.textsecuregcm.storage.PaymentTime; import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.Subscriptions; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails; @@ -115,6 +116,8 @@ class SubscriptionControllerTest { when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE)); private static final GooglePlayBillingManager PLAY_MANAGER = MockUtils.buildMock(GooglePlayBillingManager.class, mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.GOOGLE_PLAY_BILLING)); + private static final AppleAppStoreManager APPSTORE_MANAGER = MockUtils.buildMock(AppleAppStoreManager.class, + mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.APPLE_APP_STORE)); private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class); private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class); private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); @@ -122,10 +125,13 @@ class SubscriptionControllerTest { private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class); private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); - private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG, - ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS, - ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, - BANK_MANDATE_TRANSLATOR); + private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, + SUBSCRIPTION_CONFIG, ONETIME_CONFIG, + new SubscriptionManager(SUBSCRIPTIONS, + List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER), + ZK_OPS, ISSUED_RECEIPTS_MANAGER), + STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER, + BADGE_TRANSLATOR, LEVEL_TRANSLATOR, BANK_MANDATE_TRANSLATOR); private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK, ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER); private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() @@ -858,6 +864,48 @@ class SubscriptionControllerTest { .isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL); } + @Test + public void setAppStoreTransactionId() { + final String originalTxId = "aTxId"; + final byte[] subscriberUserAndKey = new byte[32]; + Arrays.fill(subscriberUserAndKey, (byte) 1); + final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16); + final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); + + final Instant now = Instant.now(); + when(CLOCK.instant()).thenReturn(now); + + final Map dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]), + Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), + Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())); + + final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem); + + when(SUBSCRIPTIONS.get(any(), any())) + .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); + + when(APPSTORE_MANAGER.validateTransaction(eq(originalTxId))) + .thenReturn(CompletableFuture.completedFuture(99L)); + + when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + + final Response response = RESOURCE_EXTENSION + .target(String.format("/v1/subscription/%s/appstore/%s", subscriberId, originalTxId)) + .request() + .post(Entity.json("")); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level()) + .isEqualTo(99L); + + verify(SUBSCRIPTIONS, times(1)).setIapPurchase( + any(), + eq(new ProcessorCustomer(originalTxId, PaymentProvider.APPLE_APP_STORE)), + eq(originalTxId), + eq(99L), + eq(now)); + } + @Test public void setPlayPurchaseToken() {