Skip to content

Commit

Permalink
Add appstore subscriptions endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
ravi-signal committed Oct 4, 2024
1 parent 02ff3f2 commit 42e920c
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,7 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
Expand Down Expand Up @@ -106,6 +107,7 @@ public class SubscriptionController {
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final GooglePlayBillingManager googlePlayBillingManager;
private final AppleAppStoreManager appleAppStoreManager;
private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator;
private final BankMandateTranslator bankMandateTranslator;
Expand All @@ -122,6 +124,7 @@ public SubscriptionController(
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
@Nonnull AppleAppStoreManager appleAppStoreManager,
@Nonnull BadgeTranslator badgeTranslator,
@Nonnull LevelTranslator levelTranslator,
@Nonnull BankMandateTranslator bankMandateTranslator) {
Expand All @@ -132,6 +135,7 @@ public SubscriptionController(
this.stripeManager = Objects.requireNonNull(stripeManager);
this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
this.appleAppStoreManager = appleAppStoreManager;
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
Expand Down Expand Up @@ -401,6 +405,39 @@ public boolean subscriptionsAreSameType(long level1, long level2) {
== subscriptionConfiguration.getSubscriptionLevel(level2).type();
}

@POST
@Path("/{subscriberId}/appstore/{originalTransactionId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Set app store subscription", description = """
Set an originalTransactionId that represents an IAP subscription made with the app store.
To set up an app store subscription:
1. Create a subscriber with `PUT subscriptions/{subscriberId}` (you must regularly refresh this subscriber)
2. [Create a subscription](https://developer.apple.com/documentation/storekit/in-app_purchase/) with the App Store
directly via StoreKit and obtain a originalTransactionId.
3. `POST` the purchaseToken here
4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the
entitlement
""")
@ApiResponse(responseCode = "200", description = "The originalTransactionId was successfully validated")
@ApiResponse(responseCode = "402", description = "The subscription transaction is incomplete or invalid")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the specified transaction does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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;
Expand Down Expand Up @@ -116,7 +117,8 @@ public CompletableFuture<Void> updateSubscriber(final SubscriberCredentials subs
.thenRun(Util.NOOP);
}

public CompletableFuture<Optional<SubscriptionInformation>> getSubscriptionInformation(final SubscriberCredentials subscriberCredentials) {
public CompletableFuture<Optional<SubscriptionInformation>> getSubscriptionInformation(
final SubscriberCredentials subscriberCredentials) {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.completedFuture(Optional.empty());
Expand Down Expand Up @@ -155,8 +157,8 @@ public record ReceiptResult(
*
* @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<ReceiptResult> createReceiptCredentials(
Expand Down Expand Up @@ -300,8 +302,9 @@ public CompletableFuture<Void> updateSubscriptionLevelForCustomer(
.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)) {
Expand Down Expand Up @@ -387,6 +390,41 @@ public CompletableFuture<Long> updatePlayBillingPurchaseToken(
.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<Long> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
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;
Expand Down Expand Up @@ -115,17 +116,22 @@ 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);
private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class);
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()
Expand Down Expand Up @@ -858,6 +864,48 @@ Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),
.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<String, AttributeValue> 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() {
Expand Down

0 comments on commit 42e920c

Please sign in to comment.