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
|
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.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
|
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"
|
"credential": "configuration"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
googlePlayBilling:
|
||||||
|
credentialsJson: secret://googlePlayBilling.credentialsJson
|
||||||
|
packageName: package.name
|
||||||
|
applicationName: test
|
||||||
|
productIdToLevel: {}
|
||||||
|
|
||||||
dynamoDbClient:
|
dynamoDbClient:
|
||||||
region: us-west-2 # AWS Region
|
region: us-west-2 # AWS Region
|
||||||
|
|
||||||
|
@ -364,6 +370,7 @@ subscription: # configuration for Stripe subscriptions
|
||||||
badgeExpiration: P30D
|
badgeExpiration: P30D
|
||||||
badgeGracePeriod: P15D
|
badgeGracePeriod: P15D
|
||||||
backupExpiration: P30D
|
backupExpiration: P30D
|
||||||
|
backupGracePeriod: P15D
|
||||||
backupFreeTierMediaDuration: P30D
|
backupFreeTierMediaDuration: P30D
|
||||||
levels:
|
levels:
|
||||||
500:
|
500:
|
||||||
|
|
|
@ -14,9 +14,16 @@
|
||||||
<firebase-admin.version>9.2.0</firebase-admin.version>
|
<firebase-admin.version>9.2.0</firebase-admin.version>
|
||||||
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
|
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
|
||||||
<sqlite4java.version>1.0.392</sqlite4java.version>
|
<sqlite4java.version>1.0.392</sqlite4java.version>
|
||||||
|
<google-androidpublisher.version>v3-rev20240820-2.0.0</google-androidpublisher.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.apis</groupId>
|
||||||
|
<artifactId>google-api-services-androidpublisher</artifactId>
|
||||||
|
<version>${google-androidpublisher.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.swagger.core.v3</groupId>
|
<groupId>io.swagger.core.v3</groupId>
|
||||||
<artifactId>swagger-jaxrs2</artifactId>
|
<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.FcmConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
|
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
|
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
|
||||||
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
||||||
|
@ -88,6 +89,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private BraintreeConfiguration braintree;
|
private BraintreeConfiguration braintree;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Valid
|
||||||
|
@JsonProperty
|
||||||
|
private GooglePlayBillingConfiguration googlePlayBilling;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
|
@ -358,6 +364,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return braintree;
|
return braintree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GooglePlayBillingConfiguration getGooglePlayBilling() {
|
||||||
|
return googlePlayBilling;
|
||||||
|
}
|
||||||
|
|
||||||
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
|
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
|
||||||
return dynamoDbClient;
|
return dynamoDbClient;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,8 +33,10 @@ import io.netty.channel.socket.nio.NioSocketChannel;
|
||||||
import io.netty.resolver.ResolvedAddressTypes;
|
import io.netty.resolver.ResolvedAddressTypes;
|
||||||
import io.netty.resolver.dns.DnsNameResolver;
|
import io.netty.resolver.dns.DnsNameResolver;
|
||||||
import io.netty.resolver.dns.DnsNameResolverBuilder;
|
import io.netty.resolver.dns.DnsNameResolverBuilder;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.KeyStore;
|
import java.security.KeyStore;
|
||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.security.cert.X509Certificate;
|
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.storage.VerificationSessions;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.util.BufferingInterceptor;
|
import org.whispersystems.textsecuregcm.util.BufferingInterceptor;
|
||||||
import org.whispersystems.textsecuregcm.util.ManagedAwsCrt;
|
import org.whispersystems.textsecuregcm.util.ManagedAwsCrt;
|
||||||
|
@ -578,6 +581,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
.build();
|
.build();
|
||||||
ExecutorService keyTransparencyCallbackExecutor = environment.lifecycle()
|
ExecutorService keyTransparencyCallbackExecutor = environment.lifecycle()
|
||||||
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
|
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
|
||||||
|
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
|
||||||
|
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));
|
||||||
|
|
||||||
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
|
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
|
||||||
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
|
.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().graphqlUrl(), currencyManager, config.getBraintree().pubSubPublisher().build(),
|
||||||
config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor,
|
config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor,
|
||||||
subscriptionProcessorRetryExecutor);
|
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(apnSender);
|
||||||
environment.lifecycle().manage(pushNotificationScheduler);
|
environment.lifecycle().manage(pushNotificationScheduler);
|
||||||
|
@ -1128,7 +1139,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
);
|
);
|
||||||
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
||||||
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
|
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(),
|
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||||
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
|
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
|
||||||
bankMandateTranslator));
|
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 badgeExpiration;
|
||||||
|
|
||||||
private final Duration backupExpiration;
|
private final Duration backupExpiration;
|
||||||
|
private final Duration backupGracePeriod;
|
||||||
private final Duration backupFreeTierMediaDuration;
|
private final Duration backupFreeTierMediaDuration;
|
||||||
private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
|
private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
|
||||||
private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;
|
private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;
|
||||||
|
@ -38,6 +39,7 @@ public class SubscriptionConfiguration {
|
||||||
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
|
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
|
||||||
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
|
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
|
||||||
@JsonProperty("backupExpiration") @Valid Duration backupExpiration,
|
@JsonProperty("backupExpiration") @Valid Duration backupExpiration,
|
||||||
|
@JsonProperty("backupGracePeriod") @Valid Duration backupGracePeriod,
|
||||||
@JsonProperty("backupFreeTierMediaDuration") @Valid Duration backupFreeTierMediaDuration,
|
@JsonProperty("backupFreeTierMediaDuration") @Valid Duration backupFreeTierMediaDuration,
|
||||||
@JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels,
|
@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) {
|
@JsonProperty("backupLevels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) {
|
||||||
|
@ -46,6 +48,7 @@ public class SubscriptionConfiguration {
|
||||||
this.backupFreeTierMediaDuration = backupFreeTierMediaDuration;
|
this.backupFreeTierMediaDuration = backupFreeTierMediaDuration;
|
||||||
this.donationLevels = donationLevels;
|
this.donationLevels = donationLevels;
|
||||||
this.backupExpiration = backupExpiration;
|
this.backupExpiration = backupExpiration;
|
||||||
|
this.backupGracePeriod = backupGracePeriod;
|
||||||
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
|
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,6 +65,10 @@ public class SubscriptionConfiguration {
|
||||||
return backupExpiration;
|
return backupExpiration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Duration getBackupGracePeriod() {
|
||||||
|
return backupGracePeriod;
|
||||||
|
}
|
||||||
|
|
||||||
public SubscriptionLevelConfiguration getSubscriptionLevel(long level) {
|
public SubscriptionLevelConfiguration getSubscriptionLevel(long level) {
|
||||||
return Optional
|
return Optional
|
||||||
.<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))
|
.<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))
|
||||||
|
|
|
@ -319,6 +319,7 @@ public class OneTimeDonationController {
|
||||||
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
|
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
|
||||||
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
|
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
|
||||||
case BRAINTREE -> braintreeManager.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 -> {
|
return paymentDetailsFut.thenCompose(paymentDetails -> {
|
||||||
|
|
|
@ -20,7 +20,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -71,10 +70,10 @@ import org.whispersystems.textsecuregcm.entities.Badge;
|
||||||
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
|
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.Subscriptions;
|
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
|
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
|
@ -253,11 +252,15 @@ public class SubscriptionController {
|
||||||
SubscriberCredentials subscriberCredentials =
|
SubscriberCredentials subscriberCredentials =
|
||||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||||
|
|
||||||
if (paymentMethodType == PaymentMethod.PAYPAL) {
|
final SubscriptionPaymentProcessor subscriptionPaymentProcessor = switch (paymentMethodType) {
|
||||||
throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal");
|
// 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;
|
||||||
final SubscriptionPaymentProcessor subscriptionPaymentProcessor = getManagerForPaymentMethod(paymentMethodType);
|
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(
|
return subscriptionManager.addPaymentMethodToCustomer(
|
||||||
subscriberCredentials,
|
subscriberCredentials,
|
||||||
|
@ -303,21 +306,11 @@ public class SubscriptionController {
|
||||||
.build());
|
.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) {
|
private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) {
|
||||||
return switch (processor) {
|
return switch (processor) {
|
||||||
case STRIPE -> stripeManager;
|
case STRIPE -> stripeManager;
|
||||||
case BRAINTREE -> braintreeManager;
|
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) {
|
private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) {
|
||||||
final Instant paidAt = receiptItem.paidAt();
|
final PaymentTime paymentTime = receiptItem.paymentTime();
|
||||||
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
|
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
|
||||||
case DONATION -> paidAt.plus(subscriptionConfiguration.getBadgeExpiration())
|
case DONATION -> paymentTime.receiptExpiration(
|
||||||
.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
subscriptionConfiguration.getBadgeExpiration(),
|
||||||
.truncatedTo(ChronoUnit.DAYS)
|
subscriptionConfiguration.getBadgeGracePeriod());
|
||||||
.plus(1, ChronoUnit.DAYS);
|
case BACKUP -> paymentTime.receiptExpiration(
|
||||||
case BACKUP -> paidAt.plus(subscriptionConfiguration.getBackupExpiration())
|
subscriptionConfiguration.getBackupExpiration(),
|
||||||
.truncatedTo(ChronoUnit.DAYS)
|
subscriptionConfiguration.getBackupGracePeriod());
|
||||||
.plus(1, ChronoUnit.DAYS);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
|
||||||
case SubscriptionException.Forbidden e -> Response.Status.FORBIDDEN;
|
case SubscriptionException.Forbidden e -> Response.Status.FORBIDDEN;
|
||||||
case SubscriptionException.InvalidArguments e -> Response.Status.BAD_REQUEST;
|
case SubscriptionException.InvalidArguments e -> Response.Status.BAD_REQUEST;
|
||||||
case SubscriptionException.ProcessorConflict e -> Response.Status.CONFLICT;
|
case SubscriptionException.ProcessorConflict e -> Response.Status.CONFLICT;
|
||||||
|
case SubscriptionException.PaymentRequired e -> Response.Status.PAYMENT_REQUIRED;
|
||||||
default -> Response.Status.INTERNAL_SERVER_ERROR;
|
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 static class PaymentRequiresAction extends InvalidArguments {
|
||||||
|
public PaymentRequiresAction(String message) {
|
||||||
|
super(message, null);
|
||||||
|
}
|
||||||
public PaymentRequiresAction() {
|
public PaymentRequiresAction() {
|
||||||
super(null, null);
|
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 static class ProcessorConflict extends SubscriptionException {
|
||||||
public ProcessorConflict(final String message) {
|
public ProcessorConflict(final String message) {
|
||||||
super(null, 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.ReceiptCredentialResponse;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||||
|
@ -63,12 +64,12 @@ public class SubscriptionManager {
|
||||||
/**
|
/**
|
||||||
* A receipt of payment from a payment provider
|
* 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
|
* @param itemId An identifier for the payment that should be unique within the payment provider. Note that
|
||||||
* must identify an actual individual charge, not the subscription as a whole.
|
* this must identify an actual individual charge, not the subscription as a whole.
|
||||||
* @param paidAt The time this payment was made
|
* @param paymentTime The time this payment was for
|
||||||
* @param level The level which this payment corresponds to
|
* @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
|
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
|
||||||
|
@ -270,6 +271,7 @@ public class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface LevelTransitionValidator {
|
public interface LevelTransitionValidator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check is a level update is valid
|
* 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) {
|
private Processor getProcessor(PaymentProvider provider) {
|
||||||
return processors.get(provider);
|
return processors.get(provider);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,9 +25,10 @@ import javax.ws.rs.ClientErrorException;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
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.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
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 Logger logger = LoggerFactory.getLogger(Subscriptions.class);
|
||||||
|
|
||||||
private static final int USER_LENGTH = 16;
|
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_USER = "U"; // B (Hash Key)
|
||||||
public static final String KEY_PASSWORD = "P"; // B
|
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) {
|
public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {
|
||||||
checkUserLength(user);
|
checkUserLength(user);
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguratio
|
||||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||||
import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
|
import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||||
|
@ -628,7 +629,7 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
|
||||||
throw new RuntimeException(e);
|
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)));
|
.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
|
* An iDEAL account
|
||||||
*/
|
*/
|
||||||
IDEAL,
|
IDEAL,
|
||||||
|
GOOGLE_PLAY_BILLING
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ public enum PaymentProvider {
|
||||||
// must be used if a provider is removed from the list
|
// must be used if a provider is removed from the list
|
||||||
STRIPE(1),
|
STRIPE(1),
|
||||||
BRAINTREE(2),
|
BRAINTREE(2),
|
||||||
|
GOOGLE_PLAY_BILLING(3),
|
||||||
;
|
;
|
||||||
|
|
||||||
private static final Map<Integer, PaymentProvider> IDS_TO_PROCESSORS = new HashMap<>();
|
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.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
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(
|
return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem(
|
||||||
subscriptionLineItem.getId(),
|
subscriptionLineItem.getId(),
|
||||||
paidAt,
|
PaymentTime.periodStart(paidAt),
|
||||||
getLevelForProduct(product)));
|
getLevelForProduct(product)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -78,12 +78,14 @@ import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.Subscriptions;
|
import org.whispersystems.textsecuregcm.storage.Subscriptions;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||||
|
@ -111,6 +113,8 @@ class SubscriptionControllerTest {
|
||||||
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
|
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
|
||||||
private static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
|
private static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
|
||||||
when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE));
|
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 PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
|
||||||
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
||||||
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.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 LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
|
||||||
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
||||||
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG,
|
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,
|
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
|
||||||
BANK_MANDATE_TRANSLATOR);
|
BANK_MANDATE_TRANSLATOR);
|
||||||
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
|
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
|
||||||
|
@ -885,7 +889,7 @@ class SubscriptionControllerTest {
|
||||||
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
|
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
|
||||||
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
|
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
|
||||||
"itemId",
|
"itemId",
|
||||||
Instant.ofEpochSecond(10).plus(Duration.ofDays(1)),
|
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
|
||||||
level
|
level
|
||||||
)));
|
)));
|
||||||
when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any()))
|
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 = """
|
private static final String SUBSCRIPTION_CONFIG_YAML = """
|
||||||
badgeExpiration: P30D
|
badgeExpiration: P30D
|
||||||
badgeGracePeriod: P15D
|
badgeGracePeriod: P15D
|
||||||
backupExpiration: P13D
|
backupExpiration: P3D
|
||||||
|
backupGracePeriod: P10D
|
||||||
backupFreeTierMediaDuration: P30D
|
backupFreeTierMediaDuration: P30D
|
||||||
backupLevels:
|
backupLevels:
|
||||||
201:
|
201:
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
package org.whispersystems.textsecuregcm.storage;
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.FOUND;
|
||||||
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.NOT_STORED;
|
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.NOT_STORED;
|
||||||
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.PASSWORD_MISMATCH;
|
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.DynamoDbExtensionSchema.Tables;
|
||||||
import org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult;
|
import org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult;
|
||||||
import org.whispersystems.textsecuregcm.storage.Subscriptions.Record;
|
import org.whispersystems.textsecuregcm.storage.Subscriptions.Record;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
|
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||||
|
|
||||||
class SubscriptionsTest {
|
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
|
@Test
|
||||||
void testProcessorAndCustomerId() {
|
void testProcessorAndCustomerId() {
|
||||||
final ProcessorCustomer processorCustomer =
|
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
|
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.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
|
directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users
|
||||||
|
|
||||||
|
|
|
@ -69,6 +69,12 @@ braintree:
|
||||||
pubSubPublisher:
|
pubSubPublisher:
|
||||||
type: stub
|
type: stub
|
||||||
|
|
||||||
|
googlePlayBilling:
|
||||||
|
credentialsJson: secret://googlePlayBilling.credentialsJson
|
||||||
|
packageName: package.name
|
||||||
|
applicationName: test
|
||||||
|
productIdToLevel: {}
|
||||||
|
|
||||||
dynamoDbClient:
|
dynamoDbClient:
|
||||||
type: local
|
type: local
|
||||||
|
|
||||||
|
@ -359,6 +365,7 @@ subscription: # configuration for Stripe subscriptions
|
||||||
badgeExpiration: P30D
|
badgeExpiration: P30D
|
||||||
badgeGracePeriod: P15D
|
badgeGracePeriod: P15D
|
||||||
backupExpiration: P30D
|
backupExpiration: P30D
|
||||||
|
backupGracePeriod: P15D
|
||||||
backupFreeTierMediaDuration: P30D
|
backupFreeTierMediaDuration: P30D
|
||||||
levels:
|
levels:
|
||||||
500:
|
500:
|
||||||
|
|
Loading…
Reference in New Issue