From 176a15dacee92819e8bad625e92d8f8ad6ddfadd Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Wed, 28 Aug 2024 14:22:37 -0500 Subject: [PATCH] Add GooglePlayBillingManager --- service/config/sample-secrets-bundle.yml | 3 + service/config/sample.yml | 7 + service/pom.xml | 7 + .../WhisperServerConfiguration.java | 10 + .../textsecuregcm/WhisperServerService.java | 14 +- .../GooglePlayBillingConfiguration.java | 24 ++ .../SubscriptionConfiguration.java | 7 + .../OneTimeDonationController.java | 1 + .../controllers/SubscriptionController.java | 44 +- .../mappers/SubscriptionExceptionMapper.java | 1 + .../textsecuregcm/storage/PaymentTime.java | 60 +++ .../storage/SubscriptionException.java | 12 + .../storage/SubscriptionManager.java | 51 ++- .../textsecuregcm/storage/Subscriptions.java | 75 +++- .../subscriptions/BraintreeManager.java | 3 +- .../GooglePlayBillingManager.java | 396 ++++++++++++++++++ .../subscriptions/PaymentMethod.java | 1 + .../subscriptions/PaymentProvider.java | 1 + .../subscriptions/StripeManager.java | 3 +- .../SubscriptionControllerTest.java | 11 +- .../storage/SubscriptionsTest.java | 57 ++- .../GooglePlayBillingManagerTest.java | 210 ++++++++++ .../resources/config/test-secrets-bundle.yml | 33 ++ service/src/test/resources/config/test.yml | 7 + 24 files changed, 999 insertions(+), 39 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/GooglePlayBillingConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/PaymentTime.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index 97f2658b4..2053795db 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -5,6 +5,9 @@ stripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request i braintree.privateKey: unset +googlePlayBilling.credentialsJson: | + { "json": true } + 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/config/sample.yml b/service/config/sample.yml index 0c8cdebe2..7ec5c1cd0 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -75,6 +75,12 @@ braintree: "credential": "configuration" } +googlePlayBilling: + credentialsJson: secret://googlePlayBilling.credentialsJson + packageName: package.name + applicationName: test + productIdToLevel: {} + dynamoDbClient: region: us-west-2 # AWS Region @@ -364,6 +370,7 @@ subscription: # configuration for Stripe subscriptions badgeExpiration: P30D badgeGracePeriod: P15D backupExpiration: P30D + backupGracePeriod: P15D backupFreeTierMediaDuration: P30D levels: 500: diff --git a/service/pom.xml b/service/pom.xml index e270657bc..5d4fc773e 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -14,9 +14,16 @@ 9.2.0 5.1.0 1.0.392 + v3-rev20240820-2.0.0 + + com.google.apis + google-api-services-androidpublisher + ${google-androidpublisher.version} + + io.swagger.core.v3 swagger-jaxrs2 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index f25f513e4..393e465a4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -36,6 +36,7 @@ import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterF import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.GenericZkConfig; +import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration; import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory; import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration; @@ -88,6 +89,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private BraintreeConfiguration braintree; + @NotNull + @Valid + @JsonProperty + private GooglePlayBillingConfiguration googlePlayBilling; + @NotNull @Valid @JsonProperty @@ -358,6 +364,10 @@ public class WhisperServerConfiguration extends Configuration { return braintree; } + public GooglePlayBillingConfiguration getGooglePlayBilling() { + return googlePlayBilling; + } + public DynamoDbClientFactory getDynamoDbClientConfiguration() { return dynamoDbClient; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e058ca917..f76a448d3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -33,8 +33,10 @@ import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.resolver.ResolvedAddressTypes; import io.netty.resolver.dns.DnsNameResolver; import io.netty.resolver.dns.DnsNameResolverBuilder; +import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.PrivateKey; import java.security.cert.X509Certificate; @@ -241,6 +243,7 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessions; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.util.BufferingInterceptor; import org.whispersystems.textsecuregcm.util.ManagedAwsCrt; @@ -578,6 +581,8 @@ public class WhisperServerService extends Application productIdToLevel) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java index e978d60fa..e97fc347c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java @@ -29,6 +29,7 @@ public class SubscriptionConfiguration { private final Duration badgeExpiration; private final Duration backupExpiration; + private final Duration backupGracePeriod; private final Duration backupFreeTierMediaDuration; private final Map donationLevels; private final Map backupLevels; @@ -38,6 +39,7 @@ public class SubscriptionConfiguration { @JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod, @JsonProperty("badgeExpiration") @Valid Duration badgeExpiration, @JsonProperty("backupExpiration") @Valid Duration backupExpiration, + @JsonProperty("backupGracePeriod") @Valid Duration backupGracePeriod, @JsonProperty("backupFreeTierMediaDuration") @Valid Duration backupFreeTierMediaDuration, @JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels, @JsonProperty("backupLevels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) { @@ -46,6 +48,7 @@ public class SubscriptionConfiguration { this.backupFreeTierMediaDuration = backupFreeTierMediaDuration; this.donationLevels = donationLevels; this.backupExpiration = backupExpiration; + this.backupGracePeriod = backupGracePeriod; this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels; } @@ -62,6 +65,10 @@ public class SubscriptionConfiguration { return backupExpiration; } + public Duration getBackupGracePeriod() { + return backupGracePeriod; + } + public SubscriptionLevelConfiguration getSubscriptionLevel(long level) { return Optional .ofNullable(this.donationLevels.get(level)) 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 d5b588624..86fe11548 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java @@ -319,6 +319,7 @@ public class OneTimeDonationController { final CompletableFuture paymentDetailsFut = switch (request.processor) { 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"); }; 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 f9f2a03a2..c17347008 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -20,7 +20,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -71,10 +70,10 @@ import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.PurchasableBadge; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; +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.storage.Subscriptions; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BankTransferType; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; @@ -253,11 +252,15 @@ public class SubscriptionController { SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); - if (paymentMethodType == PaymentMethod.PAYPAL) { - throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal"); - } - - final SubscriptionPaymentProcessor subscriptionPaymentProcessor = getManagerForPaymentMethod(paymentMethodType); + final SubscriptionPaymentProcessor subscriptionPaymentProcessor = switch (paymentMethodType) { + // 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 PAYPAL -> throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal"); + case UNKNOWN -> throw new BadRequestException("Invalid payment method"); + }; return subscriptionManager.addPaymentMethodToCustomer( subscriberCredentials, @@ -303,21 +306,11 @@ public class SubscriptionController { .build()); } - private SubscriptionPaymentProcessor getManagerForPaymentMethod(PaymentMethod paymentMethod) { - return switch (paymentMethod) { - // 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; - // PAYPAL payments can only be processed with braintree - case PAYPAL -> braintreeManager; - case UNKNOWN -> throw new BadRequestException("Invalid payment method"); - }; - } - private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) { 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"); }; } @@ -586,15 +579,14 @@ public class SubscriptionController { } private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) { - final Instant paidAt = receiptItem.paidAt(); + final PaymentTime paymentTime = receiptItem.paymentTime(); return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) { - case DONATION -> paidAt.plus(subscriptionConfiguration.getBadgeExpiration()) - .plus(subscriptionConfiguration.getBadgeGracePeriod()) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS); - case BACKUP -> paidAt.plus(subscriptionConfiguration.getBackupExpiration()) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS); + case DONATION -> paymentTime.receiptExpiration( + subscriptionConfiguration.getBadgeExpiration(), + subscriptionConfiguration.getBadgeGracePeriod()); + case BACKUP -> paymentTime.receiptExpiration( + subscriptionConfiguration.getBackupExpiration(), + subscriptionConfiguration.getBackupGracePeriod()); }; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java index 711542b4b..8d41bb454 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java @@ -21,6 +21,7 @@ public class SubscriptionExceptionMapper implements ExceptionMapper 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: