Add appstore subscriptions endpoint
This commit is contained in:
parent
02ff3f2ff4
commit
42e920cd5c
|
@ -1142,7 +1142,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
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));
|
||||
|
|
|
@ -76,6 +76,7 @@ import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
|||
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;
|
||||
|
@ -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;
|
||||
|
@ -122,6 +124,7 @@ public class SubscriptionController {
|
|||
@Nonnull StripeManager stripeManager,
|
||||
@Nonnull BraintreeManager braintreeManager,
|
||||
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
|
||||
@Nonnull AppleAppStoreManager appleAppStoreManager,
|
||||
@Nonnull BadgeTranslator badgeTranslator,
|
||||
@Nonnull LevelTranslator levelTranslator,
|
||||
@Nonnull BankMandateTranslator bankMandateTranslator) {
|
||||
|
@ -132,6 +135,7 @@ public class 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);
|
||||
|
@ -401,6 +405,39 @@ public class SubscriptionController {
|
|||
== 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}")
|
||||
|
|
|
@ -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<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());
|
||||
|
@ -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<ReceiptResult> 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<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);
|
||||
}
|
||||
|
|
|
@ -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<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() {
|
||||
|
|
Loading…
Reference in New Issue