Add GooglePlayBillingManager

This commit is contained in:
ravi-signal 2024-08-28 14:22:37 -05:00 committed by GitHub
parent 9249cf240e
commit 176a15dace
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 999 additions and 39 deletions

View File

@ -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

View File

@ -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:

View File

@ -14,9 +14,16 @@
<firebase-admin.version>9.2.0</firebase-admin.version>
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
<sqlite4java.version>1.0.392</sqlite4java.version>
<google-androidpublisher.version>v3-rev20240820-2.0.0</google-androidpublisher.version>
</properties>
<dependencies>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-androidpublisher</artifactId>
<version>${google-androidpublisher.version}</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2</artifactId>

View File

@ -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;
}

View File

@ -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<WhisperServerConfiguration
.build();
ExecutorService keyTransparencyCallbackExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
@ -738,6 +743,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getBraintree().graphqlUrl(), currencyManager, config.getBraintree().pubSubPublisher().build(),
config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor,
subscriptionProcessorRetryExecutor);
GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager(
new ByteArrayInputStream(config.getGooglePlayBilling().credentialsJson().value().getBytes(StandardCharsets.UTF_8)),
config.getGooglePlayBilling().packageName(),
config.getGooglePlayBilling().applicationName(),
config.getGooglePlayBilling().productIdToLevel(),
googlePlayBillingExecutor);
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(pushNotificationScheduler);
@ -1128,7 +1139,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
List.of(stripeManager, braintreeManager), zkReceiptOperations, issuedReceiptsManager);
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
bankMandateTranslator));

View File

@ -0,0 +1,24 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import java.util.Map;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
/**
* @param credentialsJson Service account credentials for Play Billing API
* @param packageName The app package name
* @param applicationName The app application name
* @param productIdToLevel A map of productIds offered in the play billing subscription catalog to their corresponding
* signal subscription level
*/
public record GooglePlayBillingConfiguration(
@NotNull SecretString credentialsJson,
@NotNull String packageName,
@NotBlank String applicationName,
@NotNull Map<String, Long> productIdToLevel) {}

View File

@ -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<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
private final Map<Long, SubscriptionLevelConfiguration.Backup> 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
.<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))

View File

@ -319,6 +319,7 @@ public class OneTimeDonationController {
final CompletableFuture<PaymentDetails> 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 -> {

View File

@ -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());
};
}

View File

@ -21,6 +21,7 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
case SubscriptionException.Forbidden e -> 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;
});

View File

@ -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.
* <p>
* 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);
}
}

View File

@ -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);

View File

@ -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<Long> 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);
}

View File

@ -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.
* <p>
* 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<Void> 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<Void> accessedAt(byte[] user, Instant accessedAt) {
checkUserLength(user);

View File

@ -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)));
}

View File

@ -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
* <p>
* Clients create a subscription using Play Billing directly, and then notify us about their subscription with their
* <a href="https://developer.android.com/google/play/billing/#concepts">purchaseToken</a>. This class provides methods
* for
* <ul>
* <li> <a href="https://developer.android.com/google/play/billing/security#verify">validating purchaseTokens</a> </li>
* <li> <a href="https://developer.android.com/google/play/billing/integrate#subscriptions">acknowledging purchaseTokens</a> </li>
* <li> querying the current status of a token's underlying subscription </li>
* </ul>
*/
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<String, Long> 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<String, Long> 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<String, Long> 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<Void> 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<ValidatedToken> 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<Void> 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<ReceiptItem> 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<T> {
AndroidPublisherRequest<T> 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 <R> The return type of the executed ApiCall
* @return A stage that completes with the result of the API call
*/
private <R> CompletableFuture<R> executeAsync(final ApiCall<R> 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<SubscriptionPurchaseV2> 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<SubscriptionPurchaseLineItem> 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<Instant> 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<String, SubscriptionState> 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<SubscriptionState> 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<String, AcknowledgementState> 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<AcknowledgementState> fromString(String s) {
return Optional.ofNullable(AcknowledgementState.VALUES.getOrDefault(s, null));
}
@VisibleForTesting
String apiString() {
return s;
}
}
}

View File

@ -23,4 +23,5 @@ public enum PaymentMethod {
* An iDEAL account
*/
IDEAL,
GOOGLE_PLAY_BILLING
}

View File

@ -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<Integer, PaymentProvider> IDS_TO_PROCESSORS = new HashMap<>();

View File

@ -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)));
}

View File

@ -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:

View File

@ -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 =

View File

@ -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));
}
}

View File

@ -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

View File

@ -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: