Add GooglePlayBillingManager
This commit is contained in:
parent
9249cf240e
commit
176a15dace
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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) {}
|
|
@ -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))
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -23,4 +23,5 @@ public enum PaymentMethod {
|
|||
* An iDEAL account
|
||||
*/
|
||||
IDEAL,
|
||||
GOOGLE_PLAY_BILLING
|
||||
}
|
||||
|
|
|
@ -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<>();
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue