mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 08:40:16 +01:00
Add GooglePlayBillingManager
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user