productIdToLevel,
+ @NotNull List<@NotBlank String> appleRootCerts,
+ @NotNull @Valid RetryConfiguration retry) {
+
+ public AppleAppStoreConfiguration {
+ if (retry == null) {
+ retry = new RetryConfiguration();
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java
index d403f02b0..5ae90df4d 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java
@@ -320,6 +320,7 @@ public class OneTimeDonationController {
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
+ case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
};
return paymentDetailsFut.thenCompose(paymentDetails -> {
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 4494f0bfc..9306601e7 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java
@@ -262,8 +262,8 @@ public class SubscriptionController {
// Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process
// other types (like CARD) in the future.
case CARD, SEPA_DEBIT, IDEAL -> stripeManager;
- case GOOGLE_PLAY_BILLING ->
- throw new BadRequestException("cannot create payment methods with payment type GOOGLE_PLAY_BILLING");
+ case GOOGLE_PLAY_BILLING, APPLE_APP_STORE ->
+ throw new BadRequestException("cannot create payment methods with payment type " + paymentMethodType);
case PAYPAL -> throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal");
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
};
@@ -316,7 +316,7 @@ public class SubscriptionController {
return switch (processor) {
case STRIPE -> stripeManager;
case BRAINTREE -> braintreeManager;
- case GOOGLE_PLAY_BILLING -> throw new BadRequestException("Operation cannot be performed with the GOOGLE_PLAY_BILLING payment provider");
+ case GOOGLE_PLAY_BILLING, APPLE_APP_STORE -> throw new BadRequestException("Operation cannot be performed with the " + processor + " payment provider");
};
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java
index 6ca7107fc..b589fd301 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java
@@ -52,6 +52,10 @@ public class SubscriptionException extends Exception {
public InvalidArguments(final String message, final Exception cause) {
super(cause, message);
}
+
+ public InvalidArguments(final String message) {
+ this(message, null);
+ }
}
public static class InvalidLevel extends InvalidArguments {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java
new file mode 100644
index 000000000..8c888c2e1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java
@@ -0,0 +1,319 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.subscriptions;
+
+import com.apple.itunes.storekit.client.APIException;
+import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
+import com.apple.itunes.storekit.model.AutoRenewStatus;
+import com.apple.itunes.storekit.model.Environment;
+import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
+import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
+import com.apple.itunes.storekit.model.LastTransactionsItem;
+import com.apple.itunes.storekit.model.Status;
+import com.apple.itunes.storekit.model.StatusResponse;
+import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;
+import com.apple.itunes.storekit.verification.SignedDataVerifier;
+import com.apple.itunes.storekit.verification.VerificationException;
+import com.google.common.annotations.VisibleForTesting;
+import io.github.resilience4j.retry.Retry;
+import io.micrometer.core.instrument.Metrics;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
+import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
+import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
+import org.whispersystems.textsecuregcm.storage.PaymentTime;
+import org.whispersystems.textsecuregcm.storage.SubscriptionException;
+import org.whispersystems.textsecuregcm.util.ExceptionUtils;
+
+/**
+ * Manages subscriptions made with the Apple App Store
+ *
+ * Clients create a subscription using storekit directly, and then notify us about their subscription with their
+ * subscription's originalTransactionId.
+ */
+public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
+
+ private static final Logger logger = LoggerFactory.getLogger(AppleAppStoreManager.class);
+
+ private final AppStoreServerAPIClient apiClient;
+ private final SignedDataVerifier signedDataVerifier;
+ private final ExecutorService executor;
+ private final ScheduledExecutorService retryExecutor;
+ private final Map productIdToLevel;
+
+ private static final Status[] EMPTY_STATUSES = new Status[0];
+
+ private static final String GET_SUBSCRIPTION_ERROR_COUNTER_NAME =
+ MetricsUtil.name(AppleAppStoreManager.class, "getSubscriptionsError");
+
+ private final String subscriptionGroupId;
+ private final Retry retry;
+
+
+ public AppleAppStoreManager(
+ final Environment env,
+ final String bundleId,
+ final long appAppleId,
+ final String issuerId,
+ final String keyId,
+ final String encodedKey,
+ final String subscriptionGroupId,
+ final Map productIdToLevel,
+ final List base64AppleRootCerts,
+ final RetryConfiguration retryConfiguration,
+ final ExecutorService executor,
+ final ScheduledExecutorService retryExecutor) {
+ this(new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, env),
+ new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, env, true),
+ subscriptionGroupId, productIdToLevel, retryConfiguration, executor, retryExecutor);
+ }
+
+ @VisibleForTesting
+ AppleAppStoreManager(
+ final AppStoreServerAPIClient apiClient,
+ final SignedDataVerifier signedDataVerifier,
+ final String subscriptionGroupId,
+ final Map productIdToLevel,
+ final RetryConfiguration retryConfiguration,
+ final ExecutorService executor,
+ final ScheduledExecutorService retryExecutor) {
+ this.apiClient = apiClient;
+ this.signedDataVerifier = signedDataVerifier;
+ this.subscriptionGroupId = subscriptionGroupId;
+ this.productIdToLevel = productIdToLevel;
+ this.retry = Retry.of("appstore-retry", retryConfiguration
+ .toRetryConfigBuilder()
+ .retryOnException(AppleAppStoreManager::shouldRetry).build());
+ this.executor = Objects.requireNonNull(executor);
+ this.retryExecutor = Objects.requireNonNull(retryExecutor);
+ }
+
+ @Override
+ public PaymentProvider getProvider() {
+ return PaymentProvider.APPLE_APP_STORE;
+ }
+
+
+ /**
+ * Check if the subscription with the provided originalTransactionId is valid.
+ *
+ * @param originalTransactionId The originalTransactionId associated with the subscription
+ * @return A stage that completes successfully when the transaction has been validated, or fails if the token does not
+ * represent an active subscription.
+ */
+ public CompletableFuture validateTransaction(final String originalTransactionId) {
+ return lookup(originalTransactionId).thenApplyAsync(tx -> {
+ if (!isSubscriptionActive(tx)) {
+ throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
+ }
+ return getLevel(tx);
+ }, executor);
+ }
+
+
+ /**
+ * Cancel the subscription
+ *
+ * The App Store does not support backend cancellation, so this does not actually cancel, but it does verify that the
+ * user has no active subscriptions. End-users must cancel their subscription directly through storekit before calling
+ * this method.
+ *
+ * @param originalTransactionId The originalTransactionId associated with the subscription
+ * @return A stage that completes when the subscription has successfully been cancelled
+ */
+ @Override
+ public CompletableFuture cancelAllActiveSubscriptions(String originalTransactionId) {
+ return lookup(originalTransactionId).thenApplyAsync(tx -> {
+ if (tx.signedTransaction.getStatus() != Status.EXPIRED &&
+ tx.signedTransaction.getStatus() != Status.REVOKED &&
+ tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) {
+ throw ExceptionUtils.wrap(
+ new SubscriptionException.InvalidArguments("must cancel subscription with storekit before deleting"));
+ }
+ // The subscription will not auto-renew, so we can stop tracking it
+ return null;
+ }, executor);
+ }
+
+ @Override
+ public CompletableFuture getSubscriptionInformation(final String originalTransactionId) {
+ return lookup(originalTransactionId).thenApplyAsync(tx -> {
+
+ final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) {
+ case ACTIVE -> SubscriptionStatus.ACTIVE;
+ case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;
+ case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
+ case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;
+ };
+
+ return new SubscriptionInformation(
+ getSubscriptionPrice(tx),
+ getLevel(tx),
+ Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()),
+ Instant.ofEpochMilli(tx.transaction.getExpiresDate()),
+ isSubscriptionActive(tx),
+ tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF,
+ status,
+ PaymentProvider.APPLE_APP_STORE,
+ PaymentMethod.APPLE_APP_STORE,
+ false,
+ null);
+ }, executor);
+ }
+
+
+ @Override
+ public CompletableFuture getReceiptItem(String originalTransactionId) {
+ return lookup(originalTransactionId).thenApplyAsync(tx -> {
+ if (!isSubscriptionActive(tx)) {
+ throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
+ }
+
+ // A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is
+ // guaranteed not to change for a specific renewal purchase.
+ // See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid
+ final String itemId = tx.transaction.getWebOrderLineItemId();
+ final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate()));
+
+ return new ReceiptItem(itemId, paymentTime, getLevel(tx));
+
+ }, executor);
+ }
+
+ private CompletableFuture lookup(final String originalTransactionId) {
+ return getAllSubscriptions(originalTransactionId).thenApplyAsync(statuses -> {
+
+ final SubscriptionGroupIdentifierItem item = statuses.getData().stream()
+ .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()
+ .orElseThrow(() -> ExceptionUtils.wrap(
+ new SubscriptionException.InvalidArguments("transaction did not contain a backup subscription", null)));
+
+ final List txs = item.getLastTransactions().stream()
+ .map(this::decode)
+ .filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId()))
+ .toList();
+
+ if (txs.isEmpty()) {
+ throw ExceptionUtils.wrap(
+ new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null));
+ }
+
+ if (txs.size() > 1) {
+ logger.warn("Multiple matching product transactions found for transactionId {}, only considering first",
+ originalTransactionId);
+ }
+
+ if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) {
+ // Get All Subscriptions only requires that the transaction be some transaction associated with the
+ // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the
+ // SubscriptionManager.
+ throw ExceptionUtils.wrap(
+ new SubscriptionException.InvalidArguments(
+ "transactionId was not the transaction's originalTransactionId", null));
+ }
+
+ return txs.getFirst();
+ }, executor).toCompletableFuture();
+ }
+
+ private CompletionStage getAllSubscriptions(final String originalTransactionId) {
+ Supplier> supplier = () -> CompletableFuture.supplyAsync(() -> {
+ try {
+ return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES);
+ } catch (final APIException e) {
+ Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment();
+ throw ExceptionUtils.wrap(switch (e.getApiError()) {
+ case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionException.NotFound();
+ case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);
+ case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage());
+ default -> e;
+ });
+ } catch (final IOException e) {
+ Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment();
+ throw ExceptionUtils.wrap(e);
+ }
+ }, executor);
+ return retry.executeCompletionStage(retryExecutor, supplier);
+ }
+
+ private static boolean shouldRetry(Throwable e) {
+ return ExceptionUtils.unwrap(e) instanceof APIException apiException && switch (apiException.getApiError()) {
+ case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true;
+ default -> false;
+ };
+ }
+
+ private record DecodedTransaction(
+ LastTransactionsItem signedTransaction,
+ JWSTransactionDecodedPayload transaction,
+ JWSRenewalInfoDecodedPayload renewalInfo) {}
+
+ /**
+ * Verify signature and decode transaction payloads
+ */
+ private DecodedTransaction decode(final LastTransactionsItem tx) {
+ try {
+ return new DecodedTransaction(
+ tx,
+ signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()),
+ signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo()));
+ } catch (VerificationException e) {
+ throw ExceptionUtils.wrap(new IOException("Failed to verify payload from App Store Server", e));
+ }
+ }
+
+ private SubscriptionPrice getSubscriptionPrice(final DecodedTransaction tx) {
+ final BigDecimal amount = new BigDecimal(tx.transaction.getPrice()).scaleByPowerOfTen(-3);
+ return new SubscriptionPrice(
+ tx.transaction.getCurrency().toUpperCase(Locale.ROOT),
+ SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount));
+ }
+
+ private long getLevel(final DecodedTransaction tx) {
+ final Long level = productIdToLevel.get(tx.transaction.getProductId());
+ if (level == null) {
+ throw ExceptionUtils.wrap(
+ new SubscriptionException.InvalidArguments(
+ "Transaction for unknown productId " + tx.transaction.getProductId()));
+ }
+ return level;
+ }
+
+ /**
+ * Return true if the subscription's entitlement can currently be granted
+ */
+ private boolean isSubscriptionActive(final DecodedTransaction tx) {
+ return tx.signedTransaction.getStatus() == Status.ACTIVE
+ || tx.signedTransaction.getStatus() == Status.BILLING_GRACE_PERIOD;
+ }
+
+ private static Set decodeRootCerts(final List rootCerts) {
+ return rootCerts.stream()
+ .map(Base64.getDecoder()::decode)
+ .map(ByteArrayInputStream::new)
+ .collect(Collectors.toSet());
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java
index 438c3bd6b..5cddfee33 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java
@@ -412,7 +412,6 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
}
}
-
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState
@VisibleForTesting
enum SubscriptionState {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java
index 35e0f387f..55ce64dff 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java
@@ -23,5 +23,6 @@ public enum PaymentMethod {
* An iDEAL account
*/
IDEAL,
- GOOGLE_PLAY_BILLING
+ GOOGLE_PLAY_BILLING,
+ APPLE_APP_STORE
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java
index 9112ad386..a122dfd17 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java
@@ -18,7 +18,7 @@ public enum PaymentProvider {
STRIPE(1),
BRAINTREE(2),
GOOGLE_PLAY_BILLING(3),
- ;
+ APPLE_APP_STORE(4);
private static final Map IDS_TO_PROCESSORS = new HashMap<>();
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java
new file mode 100644
index 000000000..cf7aecb0d
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.subscriptions;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.apple.itunes.storekit.client.APIError;
+import com.apple.itunes.storekit.client.APIException;
+import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
+import com.apple.itunes.storekit.model.AutoRenewStatus;
+import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
+import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
+import com.apple.itunes.storekit.model.LastTransactionsItem;
+import com.apple.itunes.storekit.model.Status;
+import com.apple.itunes.storekit.model.StatusResponse;
+import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;
+import com.apple.itunes.storekit.verification.SignedDataVerifier;
+import com.apple.itunes.storekit.verification.VerificationException;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
+import org.whispersystems.textsecuregcm.storage.SubscriptionException;
+import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
+
+class AppleAppStoreManagerTest {
+
+ private final static long LEVEL = 123L;
+ private final static String ORIGINAL_TX_ID = "originalTxIdTest";
+ private final static String SUBSCRIPTION_GROUP_ID = "subscriptionGroupIdTest";
+ private final static String SIGNED_RENEWAL_INFO = "signedRenewalInfoTest";
+ private final static String SIGNED_TX_INFO = "signedRenewalInfoTest";
+ private final static String PRODUCT_ID = "productIdTest";
+ private final static String WEB_ORDER_LINE_ITEM = "webOrderLineItemTest";
+
+ private final AppStoreServerAPIClient apiClient = mock(AppStoreServerAPIClient.class);
+ private final SignedDataVerifier signedDataVerifier = mock(SignedDataVerifier.class);
+ private ScheduledExecutorService executor;
+ private AppleAppStoreManager appleAppStoreManager;
+
+ @BeforeEach
+ public void setup() {
+ reset(apiClient, signedDataVerifier);
+ executor = Executors.newSingleThreadScheduledExecutor();
+ appleAppStoreManager = new AppleAppStoreManager(apiClient, signedDataVerifier,
+ SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), new RetryConfiguration(), executor, executor);
+ }
+
+ @AfterEach
+ public void teardown() throws InterruptedException {
+ executor.shutdownNow();
+ executor.awaitTermination(1, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void lookupTransaction() throws APIException, IOException, VerificationException {
+ mockValidSubscription();
+ final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
+
+ assertThat(info.active()).isTrue();
+ assertThat(info.paymentProcessing()).isFalse();
+ assertThat(info.level()).isEqualTo(LEVEL);
+ assertThat(info.cancelAtPeriodEnd()).isFalse();
+ assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);
+ assertThat(info.price().amount().compareTo(new BigDecimal("150"))).isEqualTo(0); // 150 cents
+ }
+
+ @Test
+ public void validateTransaction() throws VerificationException, APIException, IOException {
+ mockValidSubscription();
+ assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID).join()).isEqualTo(LEVEL);
+ }
+
+ @Test
+ public void generateReceipt() throws VerificationException, APIException, IOException {
+ mockValidSubscription();
+ final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID).join();
+ assertThat(receipt.level()).isEqualTo(LEVEL);
+ assertThat(receipt.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))
+ .isEqualTo(Instant.EPOCH.plus(Duration.ofDays(2)));
+ assertThat(receipt.itemId()).isEqualTo(WEB_ORDER_LINE_ITEM);
+ }
+
+ @Test
+ public void generateReceiptExpired() throws VerificationException, APIException, IOException {
+ mockSubscription(Status.EXPIRED, AutoRenewStatus.ON);
+ CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class,
+ appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID));
+ }
+
+ @Test
+ public void autoRenewOff() throws VerificationException, APIException, IOException {
+ mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);
+ final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
+
+ assertThat(info.cancelAtPeriodEnd()).isTrue();
+
+ assertThat(info.active()).isTrue();
+ assertThat(info.paymentProcessing()).isFalse();
+ assertThat(info.level()).isEqualTo(LEVEL);
+ assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);
+ }
+
+ @Test
+ public void lookupMultipleProducts() throws APIException, IOException, VerificationException {
+ // The lookup should select the transaction at i=1
+ final List products = List.of("otherProduct1", PRODUCT_ID, "otherProduct3");
+
+ when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
+ .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem()
+ .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)
+ .lastTransactions(products.stream().map(product -> new LastTransactionsItem()
+ .originalTransactionId(ORIGINAL_TX_ID)
+ .status(Status.ACTIVE)
+ .signedRenewalInfo(SIGNED_RENEWAL_INFO)
+ .signedTransactionInfo(product + "_signed_tx"))
+ .toList()))));
+ when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO))
+ .thenReturn(new JWSRenewalInfoDecodedPayload()
+ .autoRenewStatus(AutoRenewStatus.ON));
+
+ for (int i = 0; i < products.size(); i++) {
+ // Give each productId a different price, the selected transaction should have priceMillis 1000
+ final long priceMillis = i * 1000L;
+ final String productId = products.get(i);
+ when(signedDataVerifier.verifyAndDecodeTransaction(productId + "_signed_tx"))
+ .thenReturn(new JWSTransactionDecodedPayload()
+ .productId(productId)
+ .currency("usd").price(priceMillis)
+ .originalPurchaseDate(Instant.EPOCH.toEpochMilli())
+ .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli()));
+ }
+ final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
+
+ assertThat(info.price().amount().compareTo(new BigDecimal("100"))).isEqualTo(0);
+
+ }
+
+ @Test
+ public void retryEventuallyWorks() throws APIException, IOException, VerificationException {
+ // Should retry up to 3 times
+ when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
+ .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test"))
+ .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test"))
+ .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem()
+ .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)
+ .addLastTransactionsItem(new LastTransactionsItem()
+ .originalTransactionId(ORIGINAL_TX_ID)
+ .status(Status.ACTIVE)
+ .signedRenewalInfo(SIGNED_RENEWAL_INFO)
+ .signedTransactionInfo(SIGNED_TX_INFO)))));
+ mockDecode(AutoRenewStatus.ON);
+ final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join();
+ assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);
+ }
+
+ @Test
+ public void retryEventuallyGivesUp() throws APIException, IOException, VerificationException {
+ // Should retry up to 3 times
+ when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
+ .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test"));
+ mockDecode(AutoRenewStatus.ON);
+ CompletableFutureTestUtil.assertFailsWithCause(APIException.class,
+ appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID));
+
+ verify(apiClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});
+
+ }
+
+ @Test
+ public void cancelRenewalDisabled() throws APIException, VerificationException, IOException {
+ mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);
+ assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join());
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"EXPIRED", "REVOKED"})
+ public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException {
+ mockSubscription(status, AutoRenewStatus.ON);
+ CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.InvalidArguments.class,
+ appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"EXPIRED", "REVOKED"})
+ public void cancelInactiveStatus(Status status) throws APIException, VerificationException, IOException {
+ mockSubscription(status, AutoRenewStatus.ON);
+ assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join());
+ }
+
+ private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus)
+ throws APIException, IOException, VerificationException {
+ when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))
+ .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem()
+ .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)
+ .addLastTransactionsItem(new LastTransactionsItem()
+ .originalTransactionId(ORIGINAL_TX_ID)
+ .status(status)
+ .signedRenewalInfo(SIGNED_RENEWAL_INFO)
+ .signedTransactionInfo(SIGNED_TX_INFO)))));
+ mockDecode(autoRenewStatus);
+ }
+
+ private void mockValidSubscription() throws APIException, IOException, VerificationException {
+ mockSubscription(Status.ACTIVE, AutoRenewStatus.ON);
+ }
+
+ private void mockDecode(final AutoRenewStatus autoRenewStatus) throws VerificationException {
+ when(signedDataVerifier.verifyAndDecodeTransaction(SIGNED_TX_INFO))
+ .thenReturn(new JWSTransactionDecodedPayload()
+ .productId(PRODUCT_ID)
+ .currency("usd").price(1500L) // $1.50
+ .originalPurchaseDate(Instant.EPOCH.toEpochMilli())
+ .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli())
+ .webOrderLineItemId(WEB_ORDER_LINE_ITEM));
+ when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO))
+ .thenReturn(new JWSRenewalInfoDecodedPayload()
+ .autoRenewStatus(autoRenewStatus));
+ }
+
+}
diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml
index b98636913..a4a873ce8 100644
--- a/service/src/test/resources/config/test-secrets-bundle.yml
+++ b/service/src/test/resources/config/test-secrets-bundle.yml
@@ -39,6 +39,14 @@ googlePlayBilling.credentialsJson: |
TQkqmVUmgKKvCFTPWwCgeIOD
-----END PRIVATE KEY-----" }
+# The below private key was key generated exclusively for testing purposes. Do not use it in any other context.
+appleAppStore.encodedKey: |
+ -----BEGIN PRIVATE KEY-----
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgF32wH6zG3GFMBs38
+ pOp712zI2NBwnccdfwxI6lidSkShRANCAATUzzM68ATLZ+TD09nT03ZyxjY1MA7H
+ dCuKcyQAVQo+X5lc8TpMgTWg36Kzxb4hPU1cqshIJomI0iE70eLOUe8p
+ -----END PRIVATE KEY-----
+
directoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users
directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users
diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml
index 911b905b5..2edb863f6 100644
--- a/service/src/test/resources/config/test.yml
+++ b/service/src/test/resources/config/test.yml
@@ -75,6 +75,19 @@ googlePlayBilling:
applicationName: test
productIdToLevel: {}
+appleAppStore:
+ env: LOCAL_TESTING
+ bundleId: bundle.name
+ appAppleId: 12345
+ issuerId: abcdefg
+ keyId: abcdefg
+ encodedKey: secret://appleAppStore.encodedKey
+ subscriptionGroupId: example_subscriptionGroupId
+ productIdToLevel: {}
+ appleRootCerts:
+ # An apple root cert https://www.apple.com/certificateauthority/
+ - MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh
+
dynamoDbClient:
type: local