Add GooglePlayBillingManager

This commit is contained in:
ravi-signal
2024-08-28 14:22:37 -05:00
committed by GitHub
parent 9249cf240e
commit 176a15dace
24 changed files with 999 additions and 39 deletions

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