Response.Status.FORBIDDEN;
case SubscriptionException.InvalidArguments e -> Response.Status.BAD_REQUEST;
case SubscriptionException.ProcessorConflict e -> Response.Status.CONFLICT;
+ case SubscriptionException.PaymentRequired e -> Response.Status.PAYMENT_REQUIRED;
default -> Response.Status.INTERNAL_SERVER_ERROR;
});
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PaymentTime.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PaymentTime.java
new file mode 100644
index 000000000..9e4133f65
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PaymentTime.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.textsecuregcm.storage;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * The time at which a receipt was purchased. Some providers provide the end of the period, others the beginning. Either
+ * way, lets you calculate the expiration time for a product associated with the payment.
+ *
+ * A subscription is typically for a fixed pay period. For example, a subscription may require renewal every 30 days.
+ * Until the end of a period, a subscriber may create a receipt credential that can be cashed in for access to the
+ * purchase. This receipt credential has an expiration that at least includes the end of the payment period but may
+ * additionally include allowance (gracePeriod) for missed payments. The product obtained with the receipt will be
+ * usable until this expiration time.
+ */
+public class PaymentTime {
+
+ @Nullable
+ Instant periodStart;
+ @Nullable
+ Instant periodEnd;
+
+ private PaymentTime(@Nullable Instant periodStart, @Nullable Instant periodEnd) {
+ if ((periodStart == null && periodEnd == null) || (periodStart != null && periodEnd != null)) {
+ throw new IllegalArgumentException("Only one of periodStart and periodEnd should be provided");
+ }
+ this.periodStart = periodStart;
+ this.periodEnd = periodEnd;
+ }
+
+ public static PaymentTime periodEnds(Instant periodEnd) {
+ return new PaymentTime(null, Objects.requireNonNull(periodEnd));
+ }
+
+ public static PaymentTime periodStart(Instant periodStart) {
+ return new PaymentTime(Objects.requireNonNull(periodStart), null);
+ }
+
+ /**
+ * Calculate the expiration time for this period
+ *
+ * @param periodLength How long after the time of payment should the receipt be valid
+ * @param gracePeriod An additional grace period after the end of the period to add to the expiration
+ * @return Instant when the receipt should expire
+ */
+ public Instant receiptExpiration(final Duration periodLength, final Duration gracePeriod) {
+ final Instant expiration = periodStart != null
+ ? periodStart.plus(periodLength).plus(gracePeriod)
+ : periodEnd.plus(gracePeriod);
+
+ return expiration.truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS);
+ }
+}
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 92030fa93..255ce2ad6 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java
@@ -59,11 +59,23 @@ public class SubscriptionException extends Exception {
}
public static class PaymentRequiresAction extends InvalidArguments {
+ public PaymentRequiresAction(String message) {
+ super(message, null);
+ }
public PaymentRequiresAction() {
super(null, null);
}
}
+ public static class PaymentRequired extends SubscriptionException {
+ public PaymentRequired() {
+ super(null, null);
+ }
+ public PaymentRequired(String message) {
+ super(null, message);
+ }
+ }
+
public static class ProcessorConflict extends SubscriptionException {
public ProcessorConflict(final String message) {
super(null, message);
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 0b6a13912..4b4f9adca 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.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
@@ -63,12 +64,12 @@ public class SubscriptionManager {
/**
* A receipt of payment from a payment provider
*
- * @param itemId An identifier for the payment that should be unique within the payment provider. Note that this
- * must identify an actual individual charge, not the subscription as a whole.
- * @param paidAt The time this payment was made
- * @param level The level which this payment corresponds to
+ * @param itemId An identifier for the payment that should be unique within the payment provider. Note that
+ * this must identify an actual individual charge, not the subscription as a whole.
+ * @param paymentTime The time this payment was for
+ * @param level The level which this payment corresponds to
*/
- record ReceiptItem(String itemId, Instant paidAt, long level) {}
+ record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
/**
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
@@ -270,6 +271,7 @@ public class SubscriptionManager {
}
public interface LevelTransitionValidator {
+
/**
* Check is a level update is valid
*
@@ -353,6 +355,45 @@ public class SubscriptionManager {
});
}
+ /**
+ * Check the provided play billing purchase token and write it the subscriptions table if is valid.
+ *
+ * @param subscriberCredentials Subscriber credentials derived from the subscriberId
+ * @param googlePlayBillingManager Performs play billing API operations
+ * @param purchaseToken The client provided purchaseToken that represents a purchased subscription in the
+ * play store
+ * @return A stage that completes with the subscription level for the accepted subscription
+ */
+ public CompletableFuture updatePlayBillingPurchaseToken(
+ final SubscriberCredentials subscriberCredentials,
+ final GooglePlayBillingManager googlePlayBillingManager,
+ final String purchaseToken) {
+
+ return getSubscriber(subscriberCredentials).thenCompose(record -> {
+ if (record.processorCustomer != null
+ && record.processorCustomer.processor() != PaymentProvider.GOOGLE_PLAY_BILLING) {
+ return CompletableFuture.failedFuture(
+ new SubscriptionException.ProcessorConflict("existing processor does not match"));
+ }
+
+ // For IAP providers, the subscriptionId and the customerId are both just the purchaseToken. Changes to the
+ // subscription always just result in a new purchaseToken
+ final ProcessorCustomer pc = new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING);
+
+ return googlePlayBillingManager
+ // Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager,
+ // but we don't want to acknowledge it until it's successfully persisted.
+ .validateToken(record.subscriptionId)
+ // Store the purchaseToken with the subscriber
+ .thenCompose(validatedToken -> subscriptions.setIapPurchase(
+ record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now())
+ // Now that the purchaseToken is durable, we can acknowledge it
+ .thenCompose(ignore -> validatedToken.acknowledgePurchase())
+ .thenApply(ignore -> validatedToken.getLevel()));
+ });
+
+ }
+
private Processor getProcessor(PaymentProvider provider) {
return processors.get(provider);
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Subscriptions.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Subscriptions.java
index ee1ccb689..8a99743c9 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Subscriptions.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Subscriptions.java
@@ -25,9 +25,10 @@ import javax.ws.rs.ClientErrorException;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
+import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.util.Pair;
+import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
@@ -42,6 +43,7 @@ public class Subscriptions {
private static final Logger logger = LoggerFactory.getLogger(Subscriptions.class);
private static final int USER_LENGTH = 16;
+ private static final byte[] EMPTY_PROCESSOR = new byte[0];
public static final String KEY_USER = "U"; // B (Hash Key)
public static final String KEY_PASSWORD = "P"; // B
@@ -327,6 +329,77 @@ public class Subscriptions {
});
}
+ /**
+ * Associate an IAP subscription with a subscriberId.
+ *
+ * IAP subscriptions do not have a distinction between customerId and subscriptionId, so they should both be set
+ * simultaneously with this method instead of calling {@link #setProcessorAndCustomerId},
+ * {@link #subscriptionCreated}, and {@link #subscriptionLevelChanged}.
+ *
+ * @param record The record to update
+ * @param processorCustomer The processorCustomer. The processor component must match the existing processor, if the
+ * record already has one.
+ * @param subscriptionId The subscriptionId. For IAP subscriptions, the subscriptionId should match the
+ * customerId.
+ * @param level The corresponding level for this subscription
+ * @param updatedAt The time of this update
+ * @return A stage that completes once the record has been updated
+ */
+ public CompletableFuture setIapPurchase(
+ final Record record,
+ final ProcessorCustomer processorCustomer,
+ final String subscriptionId,
+ final long level,
+ final Instant updatedAt) {
+ if (record.processorCustomer != null && record.processorCustomer.processor() != processorCustomer.processor()) {
+ throw new IllegalArgumentException("cannot change processor on existing subscription");
+ }
+ final byte[] oldProcessorCustomerBytes = record.processorCustomer != null
+ ? record.processorCustomer.toDynamoBytes()
+ : EMPTY_PROCESSOR;
+
+ final UpdateItemRequest request = UpdateItemRequest.builder()
+ .tableName(table)
+ .key(Map.of(KEY_USER, b(record.user)))
+ .returnValues(ReturnValue.ALL_NEW)
+ .conditionExpression(
+ "attribute_not_exists(#processor_customer_id) OR #processor_customer_id = :old_processor_customer_id")
+ .updateExpression("SET "
+ + "#processor_customer_id = :processor_customer_id, "
+ + "#accessed_at = :accessed_at, "
+ + "#subscription_id = :subscription_id, "
+ + "#subscription_level = :subscription_level, "
+ + "#subscription_created_at = if_not_exists(#subscription_created_at, :subscription_created_at), "
+ + "#subscription_level_changed_at = :subscription_level_changed_at"
+ )
+ .expressionAttributeNames(Map.of(
+ "#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID,
+ "#accessed_at", KEY_ACCESSED_AT,
+ "#subscription_id", KEY_SUBSCRIPTION_ID,
+ "#subscription_level", KEY_SUBSCRIPTION_LEVEL,
+ "#subscription_created_at", KEY_SUBSCRIPTION_CREATED_AT,
+ "#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
+ .expressionAttributeValues(Map.of(
+ ":accessed_at", n(updatedAt.getEpochSecond()),
+ ":processor_customer_id", b(processorCustomer.toDynamoBytes()),
+ ":old_processor_customer_id", b(oldProcessorCustomerBytes),
+ ":subscription_id", s(subscriptionId),
+ ":subscription_level", n(level),
+ ":subscription_created_at", n(updatedAt.getEpochSecond()),
+ ":subscription_level_changed_at", n(updatedAt.getEpochSecond())))
+ .build();
+
+ return client.updateItem(request)
+ .exceptionallyCompose(throwable -> {
+ if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {
+ throw new ClientErrorException(Response.Status.CONFLICT);
+ }
+ Throwables.throwIfUnchecked(throwable);
+ throw new CompletionException(throwable);
+ })
+ .thenRun(Util.NOOP);
+ }
+
public CompletableFuture accessedAt(byte[] user, Instant accessedAt) {
checkUserLength(user);
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 18ac4c668..f70eace42 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java
@@ -48,6 +48,7 @@ import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguratio
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
+import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@@ -628,7 +629,7 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
throw new RuntimeException(e);
}
- return new ReceiptItem(transaction.getId(), paidAt, metadata.level());
+ return new ReceiptItem(transaction.getId(), PaymentTime.periodStart(paidAt), metadata.level());
})
.orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT)));
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java
new file mode 100644
index 000000000..63b25fc27
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java
@@ -0,0 +1,396 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.subscriptions;
+
+import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
+import com.google.api.client.googleapis.json.GoogleJsonResponseException;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.services.androidpublisher.AndroidPublisher;
+import com.google.api.services.androidpublisher.AndroidPublisherRequest;
+import com.google.api.services.androidpublisher.AndroidPublisherScopes;
+import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
+import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
+import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.annotations.VisibleForTesting;
+import io.micrometer.core.instrument.Metrics;
+import io.micrometer.core.instrument.Tags;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.stream.Collectors;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
+import org.whispersystems.textsecuregcm.storage.PaymentTime;
+import org.whispersystems.textsecuregcm.storage.SubscriptionException;
+import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
+import org.whispersystems.textsecuregcm.util.ExceptionUtils;
+
+/**
+ * Manages subscriptions made with the Play Billing API
+ *
+ * Clients create a subscription using Play Billing directly, and then notify us about their subscription with their
+ * purchaseToken. This class provides methods
+ * for
+ *
+ */
+public class GooglePlayBillingManager implements SubscriptionManager.Processor {
+
+ private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);
+
+ private final AndroidPublisher androidPublisher;
+ private final Executor executor;
+ private final String packageName;
+ private final Map productIdToLevel;
+ private final Clock clock;
+
+ private static final String VALIDATE_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "validate");
+ private static final String CANCEL_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "cancel");
+ private static final String GET_RECEIPT_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, "getReceipt");
+
+
+ public GooglePlayBillingManager(
+ final InputStream credentialsStream,
+ final String packageName,
+ final String applicationName,
+ final Map productIdToLevel,
+ final Executor executor)
+ throws GeneralSecurityException, IOException {
+ this(new AndroidPublisher.Builder(
+ GoogleNetHttpTransport.newTrustedTransport(),
+ GsonFactory.getDefaultInstance(),
+ new HttpCredentialsAdapter(GoogleCredentials
+ .fromStream(credentialsStream)
+ .createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER)))
+ .setApplicationName(applicationName)
+ .build(),
+ Clock.systemUTC(), packageName, productIdToLevel, executor);
+ }
+
+ @VisibleForTesting
+ GooglePlayBillingManager(
+ final AndroidPublisher androidPublisher,
+ final Clock clock,
+ final String packageName,
+ final Map productIdToLevel,
+ final Executor executor) {
+ this.clock = clock;
+ this.androidPublisher = androidPublisher;
+ this.productIdToLevel = productIdToLevel;
+ this.executor = Objects.requireNonNull(executor);
+ this.packageName = packageName;
+ }
+
+ @Override
+ public PaymentProvider getProvider() {
+ return PaymentProvider.GOOGLE_PLAY_BILLING;
+ }
+
+ /**
+ * Represents a valid purchaseToken that should be durably stored and then acknowledged with
+ * {@link #acknowledgePurchase()}
+ */
+ public class ValidatedToken {
+
+ private final long level;
+ private final String productId;
+ private final String purchaseToken;
+ // If false, the purchase has already been acknowledged
+ private final boolean requiresAck;
+
+ ValidatedToken(final long level, final String productId, final String purchaseToken, final boolean requiresAck) {
+ this.level = level;
+ this.productId = productId;
+ this.purchaseToken = purchaseToken;
+ this.requiresAck = requiresAck;
+ }
+
+ /**
+ * Acknowledge the purchase to the play billing server. If a purchase is never acknowledged, it will eventually be
+ * refunded.
+ *
+ * @return A stage that completes when the purchase has been successfully acknowledged
+ */
+ public CompletableFuture acknowledgePurchase() {
+ if (!requiresAck) {
+ // We've already acknowledged this purchase on a previous attempt, nothing to do
+ return CompletableFuture.completedFuture(null);
+ }
+ return executeAsync(pub -> pub.purchases().subscriptions()
+ .acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest()));
+ }
+
+ public long getLevel() {
+ return level;
+ }
+ }
+
+ /**
+ * Check if the purchaseToken is valid. If it's valid it should be durably associated with the user's subscriberId and
+ * then acknowledged with {@link ValidatedToken#acknowledgePurchase()}
+ *
+ * @param purchaseToken The play store billing purchaseToken that represents a subscription purchase
+ * @return A stage that completes successfully when the token has been validated, or fails if the token does not
+ * represent an active purchase
+ */
+ public CompletableFuture validateToken(String purchaseToken) {
+ return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
+
+ final SubscriptionState state = SubscriptionState
+ .fromString(subscription.getSubscriptionState())
+ .orElse(SubscriptionState.UNSPECIFIED);
+
+ Metrics.counter(VALIDATE_COUNTER_NAME, subscriptionTags(subscription)).increment();
+
+ // We only ever acknowledge valid tokens. There are cases where a subscription was once valid and then was
+ // cancelled, so the user could still be entitled to their purchase. However, if we never acknowledge it, the
+ // user's charge will eventually be refunded anyway. See
+ // https://developer.android.com/google/play/billing/integrate#pending
+ if (state != SubscriptionState.ACTIVE) {
+ throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired(
+ "Cannot acknowledge purchase for subscription in state " + subscription.getSubscriptionState()));
+ }
+
+ final AcknowledgementState acknowledgementState = AcknowledgementState
+ .fromString(subscription.getAcknowledgementState())
+ .orElse(AcknowledgementState.UNSPECIFIED);
+
+ final boolean requiresAck = switch (acknowledgementState) {
+ case ACKNOWLEDGED -> false;
+ case PENDING -> true;
+ case UNSPECIFIED -> throw ExceptionUtils.wrap(
+ new IOException("Invalid acknowledgement state " + subscription.getAcknowledgementState()));
+ };
+
+ final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
+ final long level = productIdToLevel(purchase.getProductId());
+
+ return new ValidatedToken(level, purchase.getProductId(), purchaseToken, requiresAck);
+ }, executor);
+ }
+
+
+ /**
+ * Cancel the subscription. Cancellation stops auto-renewal, but does not refund the user nor cut off access to their
+ * entitlement until their current period expires.
+ *
+ * @param purchaseToken The purchaseToken associated with the subscription
+ * @return A stage that completes when the subscription has successfully been cancelled
+ */
+ public CompletableFuture cancelAllActiveSubscriptions(String purchaseToken) {
+ return lookupSubscription(purchaseToken).thenCompose(subscription -> {
+ Metrics.counter(CANCEL_COUNTER_NAME, subscriptionTags(subscription)).increment();
+
+ final SubscriptionState state = SubscriptionState
+ .fromString(subscription.getSubscriptionState())
+ .orElse(SubscriptionState.UNSPECIFIED);
+
+ if (state == SubscriptionState.CANCELED || state == SubscriptionState.EXPIRED) {
+ // already cancelled, nothing to do
+ return CompletableFuture.completedFuture(null);
+ }
+ final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
+
+ return executeAsync(pub ->
+ pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken));
+ });
+ }
+
+
+ @Override
+ public CompletableFuture getReceiptItem(String purchaseToken) {
+ return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
+ final AcknowledgementState acknowledgementState = AcknowledgementState
+ .fromString(subscription.getAcknowledgementState())
+ .orElse(AcknowledgementState.UNSPECIFIED);
+ if (acknowledgementState != AcknowledgementState.ACKNOWLEDGED) {
+ // We should only ever generate receipts for a stored and acknowledged token.
+ logger.error("Tried to fetch receipt for purchaseToken {} that was never acknowledged", purchaseToken);
+ throw new IllegalStateException("Tried to fetch receipt for purchaseToken that was never acknowledged");
+ }
+
+ Metrics.counter(GET_RECEIPT_COUNTER_NAME, subscriptionTags(subscription)).increment();
+
+ final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
+ final Instant expiration = getExpiration(purchase)
+ .orElseThrow(() -> ExceptionUtils.wrap(new IOException("Invalid subscription expiration")));
+
+ if (expiration.isBefore(clock.instant())) {
+ // We don't need to check any state at this point, just whether the subscription is currently valid. If the
+ // subscription is in a grace period, the expiration time will be dynamically extended, see
+ // https://developer.android.com/google/play/billing/lifecycle/subscriptions#grace-period
+ throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
+ }
+
+ return new ReceiptItem(
+ subscription.getLatestOrderId(),
+ PaymentTime.periodEnds(expiration),
+ productIdToLevel(purchase.getProductId()));
+ }, executor);
+ }
+
+
+ interface ApiCall {
+
+ AndroidPublisherRequest req(AndroidPublisher publisher) throws IOException;
+ }
+
+ /**
+ * Asynchronously execute a synchronous API call from an AndroidPublisher
+ *
+ * @param apiCall A function that takes the publisher and returns the API call to execute
+ * @param The return type of the executed ApiCall
+ * @return A stage that completes with the result of the API call
+ */
+ private CompletableFuture executeAsync(final ApiCall apiCall) {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ return apiCall.req(androidPublisher).execute();
+ } catch (GoogleJsonResponseException e) {
+ if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
+ throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
+ }
+ logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), e.getDetails(), e);
+ throw ExceptionUtils.wrap(e);
+ } catch (HttpResponseException e) {
+ if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
+ throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
+ }
+ logger.warn("Unexpected HTTP status code {} from androidpublisher", e.getStatusCode(), e);
+ throw ExceptionUtils.wrap(e);
+ } catch (IOException e) {
+ throw ExceptionUtils.wrap(e);
+ }
+ }, executor);
+ }
+
+ private CompletableFuture lookupSubscription(final String purchaseToken) {
+ return executeAsync(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));
+ }
+
+ private long productIdToLevel(final String productId) {
+ final Long level = this.productIdToLevel.get(productId);
+ if (level == null) {
+ logger.error("productId={} had no associated level", productId);
+ // This was a productId a user was able to successfully purchase from our catalog,
+ // but we don't know about it. The server's configuration is behind.
+ throw new IllegalStateException("no level found for productId " + productId);
+ }
+ return level;
+ }
+
+ private SubscriptionPurchaseLineItem getLineItem(final SubscriptionPurchaseV2 subscription) {
+ final List lineItems = subscription.getLineItems();
+ if (lineItems.isEmpty()) {
+ throw new IllegalArgumentException("Subscriptions should have line items");
+ }
+ if (lineItems.size() > 1) {
+ logger.warn("{} line items found for purchase {}, expected 1", lineItems.size(), subscription.getLatestOrderId());
+ }
+ return lineItems.getFirst();
+ }
+
+ private Tags subscriptionTags(final SubscriptionPurchaseV2 subscription) {
+ final boolean expired = subscription.getLineItems().isEmpty() ||
+ getExpiration(getLineItem(subscription)).orElse(Instant.EPOCH).isBefore(clock.instant());
+ return Tags.of(
+ "expired", Boolean.toString(expired),
+ "subscriptionState", subscription.getSubscriptionState(),
+ "acknowledgementState", subscription.getAcknowledgementState());
+ }
+
+ private Optional getExpiration(final SubscriptionPurchaseLineItem purchaseLineItem) {
+ if (StringUtils.isBlank(purchaseLineItem.getExpiryTime())) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Instant.parse(purchaseLineItem.getExpiryTime()));
+ } catch (DateTimeParseException e) {
+ logger.warn("received an expiry time with an invalid format: {}", purchaseLineItem.getExpiryTime());
+ return Optional.empty();
+ }
+ }
+
+ // https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState
+ @VisibleForTesting
+ enum SubscriptionState {
+ UNSPECIFIED("SUBSCRIPTION_STATE_UNSPECIFIED"),
+ PENDING("SUBSCRIPTION_STATE_PENDING"),
+ ACTIVE("SUBSCRIPTION_STATE_ACTIVE"),
+ PAUSED("SUBSCRIPTION_STATE_PAUSED"),
+ IN_GRACE_PERIOD("SUBSCRIPTION_STATE_IN_GRACE_PERIOD"),
+ ON_HOLD("SUBSCRIPTION_STATE_ON_HOLD"),
+ CANCELED("SUBSCRIPTION_STATE_CANCELED"),
+ EXPIRED("SUBSCRIPTION_STATE_EXPIRED"),
+ PENDING_PURCHASE_CANCELED("SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED");
+
+ private static final Map VALUES = Arrays
+ .stream(SubscriptionState.values())
+ .collect(Collectors.toMap(ss -> ss.s, ss -> ss));
+
+ private final String s;
+
+ SubscriptionState(String s) {
+ this.s = s;
+ }
+
+ private static Optional fromString(String s) {
+ return Optional.ofNullable(SubscriptionState.VALUES.getOrDefault(s, null));
+ }
+
+ @VisibleForTesting
+ String apiString() {
+ return s;
+ }
+ }
+
+ // https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#AcknowledgementState
+ @VisibleForTesting
+ enum AcknowledgementState {
+ UNSPECIFIED("ACKNOWLEDGEMENT_STATE_UNSPECIFIED"),
+ PENDING("ACKNOWLEDGEMENT_STATE_PENDING"),
+ ACKNOWLEDGED("ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED");
+
+ private static final Map VALUES = Arrays
+ .stream(AcknowledgementState.values())
+ .collect(Collectors.toMap(as -> as.s, ss -> ss));
+
+ private final String s;
+
+ AcknowledgementState(String s) {
+ this.s = s;
+ }
+
+ private static Optional fromString(String s) {
+ return Optional.ofNullable(AcknowledgementState.VALUES.getOrDefault(s, null));
+ }
+
+ @VisibleForTesting
+ String apiString() {
+ return s;
+ }
+ }
+
+}
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 2dfa09d92..35e0f387f 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java
@@ -23,4 +23,5 @@ public enum PaymentMethod {
* An iDEAL account
*/
IDEAL,
+ GOOGLE_PLAY_BILLING
}
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 ebd00462a..9112ad386 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java
@@ -17,6 +17,7 @@ public enum PaymentProvider {
// must be used if a provider is removed from the list
STRIPE(1),
BRAINTREE(2),
+ GOOGLE_PLAY_BILLING(3),
;
private static final Map IDS_TO_PROCESSORS = new HashMap<>();
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 3df2eb091..0bafda734 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java
@@ -73,6 +73,7 @@ import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.util.Conversions;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@@ -645,7 +646,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
}
return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem(
subscriptionLineItem.getId(),
- paidAt,
+ PaymentTime.periodStart(paidAt),
getLevelForProduct(product)));
}
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 797ce6f51..6c04af602 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java
@@ -78,12 +78,14 @@ import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
+import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
+import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
@@ -111,6 +113,8 @@ class SubscriptionControllerTest {
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
private static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
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 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);
@@ -119,7 +123,7 @@ class SubscriptionControllerTest {
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), ZK_OPS,
+ ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS,
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
BANK_MANDATE_TRANSLATOR);
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
@@ -885,7 +889,7 @@ class SubscriptionControllerTest {
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
"itemId",
- Instant.ofEpochSecond(10).plus(Duration.ofDays(1)),
+ PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
level
)));
when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any()))
@@ -1111,7 +1115,8 @@ class SubscriptionControllerTest {
private static final String SUBSCRIPTION_CONFIG_YAML = """
badgeExpiration: P30D
badgeGracePeriod: P15D
- backupExpiration: P13D
+ backupExpiration: P3D
+ backupGracePeriod: P10D
backupFreeTierMediaDuration: P30D
backupLevels:
201:
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionsTest.java
index 881d3f76b..646625731 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionsTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionsTest.java
@@ -6,6 +6,8 @@
package org.whispersystems.textsecuregcm.storage;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Fail.fail;
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.FOUND;
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.NOT_STORED;
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.PASSWORD_MISMATCH;
@@ -27,8 +29,9 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
import org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult;
import org.whispersystems.textsecuregcm.storage.Subscriptions.Record;
-import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
+import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
+import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
class SubscriptionsTest {
@@ -234,6 +237,58 @@ class SubscriptionsTest {
});
}
+ @Test
+ void testSetIapPurchase() {
+ Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
+ long level = 100;
+
+ ProcessorCustomer pc = new ProcessorCustomer("customerId", PaymentProvider.GOOGLE_PLAY_BILLING);
+ Record record = subscriptions.create(user, password, created).join();
+
+ // Should be able to set a fresh subscription
+ assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", level, at))
+ .succeedsWithin(DEFAULT_TIMEOUT);
+
+ record = subscriptions.get(user, password).join().record;
+ assertThat(record.subscriptionLevel).isEqualTo(level);
+ assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);
+ assertThat(record.subscriptionCreatedAt).isEqualTo(at);
+ assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
+
+ // should be able to update the level
+ Instant nextAt = at.plus(Duration.ofSeconds(10));
+ long nextLevel = level + 1;
+ assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt))
+ .succeedsWithin(DEFAULT_TIMEOUT);
+
+ record = subscriptions.get(user, password).join().record;
+ assertThat(record.subscriptionLevel).isEqualTo(nextLevel);
+ assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);
+ assertThat(record.subscriptionCreatedAt).isEqualTo(at);
+ assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
+
+ nextAt = nextAt.plus(Duration.ofSeconds(10));
+ nextLevel = level + 1;
+
+ pc = new ProcessorCustomer("newCustomerId", PaymentProvider.STRIPE);
+ try {
+ subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt).join();
+ fail("should not be able to change the processor for an existing subscription record");
+ } catch (IllegalArgumentException e) {
+ }
+
+ // should be able to change the customerId of an existing record if the processor matches
+ pc = new ProcessorCustomer("newCustomerId", PaymentProvider.GOOGLE_PLAY_BILLING);
+ assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt))
+ .succeedsWithin(DEFAULT_TIMEOUT);
+
+ record = subscriptions.get(user, password).join().record;
+ assertThat(record.subscriptionLevel).isEqualTo(nextLevel);
+ assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);
+ assertThat(record.subscriptionCreatedAt).isEqualTo(at);
+ assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
+ }
+
@Test
void testProcessorAndCustomerId() {
final ProcessorCustomer processorCustomer =
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java
new file mode 100644
index 000000000..e8cf23a0a
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+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.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.api.services.androidpublisher.AndroidPublisher;
+import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
+import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+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.storage.SubscriptionException;
+import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
+import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
+import org.whispersystems.textsecuregcm.util.MockUtils;
+import org.whispersystems.textsecuregcm.util.MutableClock;
+
+class GooglePlayBillingManagerTest {
+
+ private static final String PRODUCT_ID = "productId";
+ private static final String PACKAGE_NAME = "package.name";
+ private static final String PURCHASE_TOKEN = "purchaseToken";
+ private static final String ORDER_ID = "orderId";
+
+ // Returned in response to a purchases.subscriptionsv2.get
+ private final AndroidPublisher.Purchases.Subscriptionsv2.Get subscriptionsv2Get =
+ mock(AndroidPublisher.Purchases.Subscriptionsv2.Get.class);
+
+ // Returned in response to a purchases.subscriptions.acknowledge
+ private final AndroidPublisher.Purchases.Subscriptions.Acknowledge acknowledge =
+ mock(AndroidPublisher.Purchases.Subscriptions.Acknowledge.class);
+
+ // Returned in response to a purchases.subscriptionscancel.
+ private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =
+ mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class);
+
+ private final MutableClock clock = MockUtils.mutableClock(0L);
+
+ private ExecutorService executor;
+ private GooglePlayBillingManager googlePlayBillingManager;
+
+ @BeforeEach
+ public void setup() throws IOException {
+ reset(subscriptionsv2Get);
+ clock.setTimeMillis(0L);
+
+ AndroidPublisher androidPublisher = mock(AndroidPublisher.class);
+ AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.class);
+
+ AndroidPublisher.Purchases.Subscriptionsv2 subscriptionsv2 = mock(AndroidPublisher.Purchases.Subscriptionsv2.class);
+ when(androidPublisher.purchases()).thenReturn(purchases);
+ when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2);
+ when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get);
+
+ AndroidPublisher.Purchases.Subscriptions subscriptions = mock(AndroidPublisher.Purchases.Subscriptions.class);
+ when(purchases.subscriptions()).thenReturn(subscriptions);
+ when(subscriptions.acknowledge(eq(PACKAGE_NAME), eq(PRODUCT_ID), eq(PURCHASE_TOKEN), any()))
+ .thenReturn(acknowledge);
+ when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))
+ .thenReturn(cancel);
+
+ executor = Executors.newSingleThreadExecutor();
+ googlePlayBillingManager = new GooglePlayBillingManager(
+ androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
+ }
+
+ @AfterEach
+ public void teardown() throws InterruptedException {
+ executor.shutdownNow();
+ executor.awaitTermination(1, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void validatePurchase() throws IOException {
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
+ .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
+ .setProductId(PRODUCT_ID))));
+
+ final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
+ .validateToken(PURCHASE_TOKEN).join();
+
+ assertThat(result.getLevel()).isEqualTo(201);
+ assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
+ verify(acknowledge, times(1)).execute();
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"ACTIVE"})
+ public void rejectInactivePurchase(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
+ .setSubscriptionState(subscriptionState.apiString())
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
+ .setProductId(PRODUCT_ID))));
+
+ CompletableFutureTestUtil.assertFailsWithCause(
+ SubscriptionException.PaymentRequired.class,
+ googlePlayBillingManager.validateToken(PURCHASE_TOKEN));
+ }
+
+ @Test
+ public void avoidDoubleAcknowledge() throws IOException {
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
+ .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
+ .setProductId(PRODUCT_ID))));
+
+ final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
+ .validateToken(PURCHASE_TOKEN).join();
+
+ assertThat(result.getLevel()).isEqualTo(201);
+ assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
+ verifyNoInteractions(acknowledge);
+ }
+
+ @ParameterizedTest
+ @EnumSource
+ public void cancel(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
+ .setSubscriptionState(subscriptionState.apiString())
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
+ .setProductId(PRODUCT_ID))));
+ assertThatNoException().isThrownBy(() ->
+ googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join());
+ final int wanted = switch (subscriptionState) {
+ case CANCELED, EXPIRED -> 0;
+ default -> 1;
+ };
+ verify(cancel, times(wanted)).execute();
+ }
+
+ @Test
+ public void getReceiptUnacknowledged() throws IOException {
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
+ .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
+ .setProductId(PRODUCT_ID))));
+ CompletableFutureTestUtil.assertFailsWithCause(
+ IllegalStateException.class,
+ googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
+ }
+
+ @Test
+ public void getReceiptExpiring() throws IOException {
+ final Instant day9 = Instant.EPOCH.plus(Duration.ofDays(9));
+ final Instant day10 = Instant.EPOCH.plus(Duration.ofDays(10));
+
+ when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
+ .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
+ .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.CANCELED.apiString())
+ .setLatestOrderId(ORDER_ID)
+ .setLineItems(List.of(new SubscriptionPurchaseLineItem()
+ .setExpiryTime(day10.toString().toString())
+ .setProductId(PRODUCT_ID))));
+
+ clock.setTimeInstant(day9);
+ SubscriptionManager.Processor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
+ assertThat(item.itemId()).isEqualTo(ORDER_ID);
+ assertThat(item.level()).isEqualTo(201L);
+
+ // receipt expirations rounded to nearest next day
+ assertThat(item.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))
+ .isEqualTo(day10.plus(Duration.ofDays(1)));
+
+ // should still be able to get a receipt the next day
+ clock.setTimeInstant(day10);
+ item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
+ assertThat(item.itemId()).isEqualTo(ORDER_ID);
+
+ // next second should be expired
+ clock.setTimeInstant(day10.plus(Duration.ofSeconds(1)));
+
+ CompletableFutureTestUtil.assertFailsWithCause(
+ SubscriptionException.PaymentRequired.class,
+ googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
+ }
+
+}
diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml
index 4fb7dfa5f..b98636913 100644
--- a/service/src/test/resources/config/test-secrets-bundle.yml
+++ b/service/src/test/resources/config/test-secrets-bundle.yml
@@ -6,6 +6,39 @@ stripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request i
braintree.privateKey: unset
+# The below private key was key generated exclusively for testing purposes. Do not use it in any other context.
+googlePlayBilling.credentialsJson: |
+ { "type": "service_account", "client_id": "client_id", "client_email": "fake@example.com",
+ "private_key_id": "id",
+ "private_key": "-----BEGIN PRIVATE KEY-----
+ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrfHLw9zr/8mTX
+ c0YMN3P9pNLtn+JCsNx/6sz/7FYoJjH8CKG4zNgcJLATLGxQikTjD6yNDlgkpByD
+ qOmgXgZvIBBJadbbl+plJbU4kKwTRwdrYiq/ICMkVZBk5jfqYqSxzdw80ytj5Tha
+ 3M/3uqto7qELK91z/5cCC6pVsQXIrTqq4D41XyORKF2u4eeKOz3jiuXkdxRj4Vsb
+ MDwcS1WEi1ApoG50tDDn7e9mk3MAeE5L54ROHkd7FM471LRSU9ytpOzcH56tExLP
+ 21nN5vXZoyJnNvbgd1KZeZajjH+XHJS/wiqNAPEX2yvrFID4ECQMIonXtYyNDkmY
+ YxggNaCnAgMBAAECggEAFLDJStr+8A7BArXSh9AmWz4zLPSTiim+EQ5gJFN8Tw/S
+ DBob2SjuEkc4RLf2waj33XrwqNGdlPOFdTqWJavylB8xl99V9dzYgn0QO9OeJMf3
+ Kd+y+f3Yqkj188FLPH52Z0ryqGwaL3gNWqPge9VhWncgUIa/C4CVKcFakJ2b7bW2
+ NIk2bSMCNW8rptQZ+tWV9k86OAxjIocLbkpPgigRk6T3MAunMGVf6iviNSnOyOlZ
+ qmAPkRVs2uyK3Hnl0lEavaBW3KRs0ChU0rkfXHvGmi7V6aZ4rnG6OdRQiOgk3NYf
+ qQYqhnRMmN4st2WN6CDDdpk5o2pHR625Wqx11t/50QKBgQDmf+fYWKdQa8r+TO4w
+ 32JAiEdmFuA8fSEOaWyBik/NliJIPEApGMWLuZSmSzW80l4vt5zQ3LVgvRrxZv2y
+ 7odLxUP9jpFGVg3NpCB27nES+psmo7X4kXIfzPWGvkOs2HLpp8elVEPeOn7gkng9
+ XXXmB9vja8g/Jo9ym9FkigB0LQKBgQC+dTFTPvvVYFQ1KmeL94EOEL21ZXkgwjnx
+ 1BcnqK4p0M1NQ2xW1wwCljxlEQx5P6UY9HRWS6DecVpj6P7nRF2HWB+xsaO1aPZj
+ nMOETrUXGq8ksQml+0kI5f0A2w22wzpj3+kjiXSFBjxoWLAfKPHMKeUg/oYRfIVp
+ LeShMptIowKBgQC4H44U3ORyMlkKAGv4sEhs4i+elkFzMEU6nO4nIFQVFou2BiL+
+ cSJENe9PUx7PAYBpP5PNp7BfYU/na+zWhQGgfiiMn9jeRZlrHmMsfdXnYjaTjAyt
+ TYnLa07p3oxywsgwa2zoXUKFf1agj3/rDQBDyx1UMmHYSDYoR93hIPex1QKBgQCF
+ 4y6sna89ff1Ubp3iKDjiIWSre00eeUtwtC8e4xakMLPSZ95mYcCApQqJ5eVF6zbt
+ hxOtgnbxSPBJIgbnnwi813dYXE+AfOwQdKiBfy8QseKDwazNsQvTpJIqItPOMgn/
+ Ie3r3Ho79XlLxWTyUr9ATgdUHXk0G7xRh0CdDU1aTwKBgC5kDNr/R2XIWZL0TMzz
+ EVL2BkL11YumIpEBm+Hkx6fm3uCgR/ywMqplGdZcD+D5r0fUsckbOd1z6fFGAJqe
+ QJ3/4qaA+dcWPwB5GiKa1WIs48GJMyPrFciindEwr3BaDhhB9cEdxpVY2e/KEeZL
+ TQkqmVUmgKKvCFTPWwCgeIOD
+ -----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 a5b9c9309..c062e3dd3 100644
--- a/service/src/test/resources/config/test.yml
+++ b/service/src/test/resources/config/test.yml
@@ -69,6 +69,12 @@ braintree:
pubSubPublisher:
type: stub
+googlePlayBilling:
+ credentialsJson: secret://googlePlayBilling.credentialsJson
+ packageName: package.name
+ applicationName: test
+ productIdToLevel: {}
+
dynamoDbClient:
type: local
@@ -359,6 +365,7 @@ subscription: # configuration for Stripe subscriptions
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P30D
+ backupGracePeriod: P15D
backupFreeTierMediaDuration: P30D
levels:
500: