Add `playbilling` endpoint to `/v1/subscriptions`

This commit is contained in:
ravi-signal 2024-08-30 12:50:18 -05:00 committed by GitHub
parent 3b4d445ca8
commit 564dba3053
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 614 additions and 272 deletions

View File

@ -1142,8 +1142,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
bankMandateTranslator));
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
}

View File

@ -47,7 +47,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
@ -58,7 +57,7 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@ -166,7 +165,7 @@ public class OneTimeDonationController {
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/
private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount,
SubscriptionPaymentProcessor manager) {
CustomerAwareSubscriptionPaymentProcessor manager) {
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
.contains(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)

View File

@ -13,6 +13,7 @@ import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@ -78,11 +79,12 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@ -102,6 +104,7 @@ public class SubscriptionController {
private final SubscriptionManager subscriptionManager;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final GooglePlayBillingManager googlePlayBillingManager;
private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator;
private final BankMandateTranslator bankMandateTranslator;
@ -117,6 +120,7 @@ public class SubscriptionController {
@Nonnull SubscriptionManager subscriptionManager,
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
@Nonnull BadgeTranslator badgeTranslator,
@Nonnull LevelTranslator levelTranslator,
@Nonnull BankMandateTranslator bankMandateTranslator) {
@ -126,13 +130,14 @@ public class SubscriptionController {
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
}
private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {
final List<SubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
final List<CustomerAwareSubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
return oneTimeDonationConfiguration.currencies()
.entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
@ -252,7 +257,7 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final SubscriptionPaymentProcessor subscriptionPaymentProcessor = switch (paymentMethodType) {
final CustomerAwareSubscriptionPaymentProcessor customerAwareSubscriptionPaymentProcessor = 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;
@ -264,11 +269,11 @@ public class SubscriptionController {
return subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
subscriptionPaymentProcessor,
customerAwareSubscriptionPaymentProcessor,
getClientPlatform(userAgentString),
SubscriptionPaymentProcessor::createPaymentMethodSetupToken)
CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken)
.thenApply(token ->
Response.ok(new CreatePaymentMethodResponse(token, subscriptionPaymentProcessor.getProvider())).build());
Response.ok(new CreatePaymentMethodResponse(token, customerAwareSubscriptionPaymentProcessor.getProvider())).build());
}
public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {}
@ -306,7 +311,7 @@ public class SubscriptionController {
.build());
}
private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) {
private CustomerAwareSubscriptionPaymentProcessor getCustomerAwareProcessor(PaymentProvider processor) {
return switch (processor) {
case STRIPE -> stripeManager;
case BRAINTREE -> braintreeManager;
@ -326,7 +331,7 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processor);
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processor);
return setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);
}
@ -369,7 +374,7 @@ public class SubscriptionController {
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,
processorCustomer.processor());
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processorCustomer.processor());
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processorCustomer.processor());
return subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,
currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);
})
@ -395,6 +400,43 @@ public class SubscriptionController {
== subscriptionConfiguration.getSubscriptionLevel(level2).type();
}
@POST
@Path("/{subscriberId}/playbilling/{purchaseToken}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Set a google play billing purchase token", description = """
Set a purchaseToken that represents an IAP subscription made with Google Play Billing.
To set up a subscription with Google Play Billing:
1. Create a subscriber with `PUT subscriptions/{subscriberId}` (you must regularly refresh this subscriber)
2. [Create a subscription](https://developer.android.com/google/play/billing/integrate) with Google Play Billing
directly and obtain a purchaseToken. Do not [acknowledge](https://developer.android.com/google/play/billing/integrate#subscriptions)
the purchaseToken.
3. `POST` the purchaseToken here
4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the
entitlement
After calling this method, the payment is confirmed. Callers must durably store their subscriberId before calling
this method to ensure their payment is tracked.
""")
@ApiResponse(responseCode = "200", description = "The purchaseToken was validated and acknowledged")
@ApiResponse(responseCode = "402", description = "The purchaseToken payment is incomplete or invalid")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager
.updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager, purchaseToken)
.thenApply(SetSubscriptionLevelSuccessResponse::new);
}
@Schema(description = """
Comprehensive configuration for donation subscriptions, backup subscriptions, gift subscriptions, and one-time
donations pricing information for all levels are included in currencies. All levels that have an associated
@ -472,49 +514,89 @@ public class SubscriptionController {
public record GetBankMandateResponse(String mandate) {}
public record GetSubscriptionInformationResponse(
@Schema(description = "Information about the subscription, or null if no subscription is present")
SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,
@Schema(description = "May be omitted entirely if no charge failure is detected")
@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {
public record Subscription(long level, Instant billingCycleAnchor, Instant endOfCurrentPeriod, boolean active,
boolean cancelAtPeriodEnd, String currency, BigDecimal amount, String status,
PaymentProvider processor, PaymentMethod paymentMethod, boolean paymentProcessing) {
public record Subscription(
@Schema(description = "The subscription level")
long level,
}
}
@Schema(
description = "If present, UNIX Epoch Timestamp in seconds, can be used to calculate next billing date. May be absent for IAP subscriptions",
externalDocs = @ExternalDocumentation(description = "Calculate next billing date", url = "https://stripe.com/docs/billing/subscriptions/billing-cycle"))
Instant billingCycleAnchor,
@Schema(description = "UNIX Epoch Timestamp in seconds, when the current subscription period ends")
Instant endOfCurrentPeriod,
@Schema(description = "Whether there is a currently active subscription")
boolean active,
@Schema(description = "If true, an active subscription will not auto-renew at the end of the current period")
boolean cancelAtPeriodEnd,
@Schema(description = "A three-letter ISO 4217 currency code for currency used in the subscription")
String currency,
@Schema(
description = "The amount paid for the subscription in the currency's smallest unit",
externalDocs = @ExternalDocumentation(description = "Stripe Currencies", url = "https://docs.stripe.com/currencies"))
BigDecimal amount,
@Schema(
description = "The subscription's status, mapped to Stripe's statuses. trialing will never be returned",
externalDocs = @ExternalDocumentation(description = "Stripe subscription statuses", url = "https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses"))
String status,
@Schema(description = "The payment provider associated with the subscription")
PaymentProvider processor,
@Schema(description = "The payment method associated with the subscription")
PaymentMethod paymentMethod,
@Schema(description = "Whether the latest invoice for the subscription is in a non-terminal state")
boolean paymentProcessing) {}
}
@GET
@Path("/{subscriberId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Subscription information", description = """
Returns information about the current subscription associated with the provided subscriberId if one exists.
Although it uses [Stripes values](https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses),
the status field in the response is generic, with [Braintree-specific values](https://developer.paypal.com/braintree/docs/guides/recurring-billing/overview#subscription-statuses) mapped
to Stripe's. Since we dont support trials or unpaid subscriptions, the associated statuses will never be returned
by the API.
""")
@ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class)))
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
public CompletableFuture<Response> getSubscriptionInformation(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.getSubscriber(subscriberCredentials)
.thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build());
}
final SubscriptionPaymentProcessor manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor());
return manager.getSubscription(record.subscriptionId).thenCompose(subscription ->
manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> Response.ok(
new GetSubscriptionInformationResponse(
new GetSubscriptionInformationResponse.Subscription(
subscriptionInformation.level(),
subscriptionInformation.billingCycleAnchor(),
subscriptionInformation.endOfCurrentPeriod(),
subscriptionInformation.active(),
subscriptionInformation.cancelAtPeriodEnd(),
subscriptionInformation.price().currency(),
subscriptionInformation.price().amount(),
subscriptionInformation.status().getApiValue(),
manager.getProvider(),
subscriptionInformation.paymentMethod(),
subscriptionInformation.paymentProcessing()),
subscriptionInformation.chargeFailure()
)).build()));
});
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.getSubscriptionInformation(subscriberCredentials).thenApply(maybeInfo -> maybeInfo
.map(subscriptionInformation -> Response.ok(
new GetSubscriptionInformationResponse(
new GetSubscriptionInformationResponse.Subscription(
subscriptionInformation.level(),
subscriptionInformation.billingCycleAnchor(),
subscriptionInformation.endOfCurrentPeriod(),
subscriptionInformation.active(),
subscriptionInformation.cancelAtPeriodEnd(),
subscriptionInformation.price().currency(),
subscriptionInformation.price().amount(),
subscriptionInformation.status().getApiValue(),
subscriptionInformation.paymentProvider(),
subscriptionInformation.paymentMethod(),
subscriptionInformation.paymentProcessing()),
subscriptionInformation.chargeFailure()
)).build())
.orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
}
public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) {
@ -536,7 +618,7 @@ public class SubscriptionController {
return subscriptionManager.createReceiptCredentials(subscriberCredentials, request, this::receiptExpirationWithGracePeriod)
.thenApply(receiptCredential -> {
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final SubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),
@ -564,7 +646,7 @@ public class SubscriptionController {
.thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials));
}
private CompletableFuture<Response> setDefaultPaymentMethod(final SubscriptionPaymentProcessor manager,
private CompletableFuture<Response> setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,
final String paymentMethodId,
final SubscriberCredentials requestData) {
return subscriptionManager.getSubscriber(requestData)
@ -578,7 +660,7 @@ public class SubscriptionController {
.thenApply(customer -> Response.ok().build());
}
private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) {
private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {
final PaymentTime paymentTime = receiptItem.paymentTime();
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
case DONATION -> paymentTime.receiptExpiration(

View File

@ -22,9 +22,11 @@ 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.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Util;
@ -41,54 +43,22 @@ import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public class SubscriptionManager {
private final Subscriptions subscriptions;
private final EnumMap<PaymentProvider, Processor> processors;
private final EnumMap<PaymentProvider, SubscriptionPaymentProcessor> processors;
private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager;
public SubscriptionManager(
@Nonnull Subscriptions subscriptions,
@Nonnull List<Processor> processors,
@Nonnull List<SubscriptionPaymentProcessor> processors,
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager) {
this.subscriptions = Objects.requireNonNull(subscriptions);
this.processors = new EnumMap<>(processors.stream()
.collect(Collectors.toMap(Processor::getProvider, Function.identity())));
.collect(Collectors.toMap(SubscriptionPaymentProcessor::getProvider, Function.identity())));
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
}
public interface Processor {
PaymentProvider getProvider();
/**
* 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 paymentTime The time this payment was for
* @param level The level which this payment corresponds to
*/
record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
/**
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
*
* @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription
* @return A {@link ReceiptItem} if the subscription is valid
*/
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
/**
* Cancel all active subscriptions for this key within the payment provider.
*
* @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in
* the subscriptions table
* @return A stage that completes when all subscriptions associated with the key are cancelled
*/
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
}
/**
* Cancel a subscription with the upstream payment provider and remove the subscription from the table
*
@ -146,6 +116,16 @@ public class SubscriptionManager {
.thenRun(Util.NOOP);
}
public CompletableFuture<Optional<SubscriptionInformation>> getSubscriptionInformation(final SubscriberCredentials subscriberCredentials) {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.completedFuture(Optional.empty());
}
final SubscriptionPaymentProcessor manager = getProcessor(record.processorCustomer.processor());
return manager.getSubscriptionInformation(record.subscriptionId).thenApply(Optional::of);
});
}
/**
* Get the subscriber record
*
@ -167,7 +147,7 @@ public class SubscriptionManager {
public record ReceiptResult(
ReceiptCredentialResponse receiptCredentialResponse,
SubscriptionPaymentProcessor.ReceiptItem receiptItem,
CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem,
PaymentProvider paymentProvider) {}
/**
@ -175,14 +155,14 @@ public class SubscriptionManager {
*
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
* @param request The ZK Receipt credential request
* @param expiration A function that takes a {@link SubscriptionPaymentProcessor.ReceiptItem} and returns
* @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} and returns
* the expiration time of the receipt
* @return If the subscription had a valid payment, the requested ZK receipt credential
*/
public CompletableFuture<ReceiptResult> createReceiptCredentials(
final SubscriberCredentials subscriberCredentials,
final SubscriptionController.GetReceiptCredentialsRequest request,
final Function<SubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.failedFuture(new SubscriptionException.NotFound());
@ -197,7 +177,7 @@ public class SubscriptionManager {
}
final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();
final Processor manager = getProcessor(processor);
final SubscriptionPaymentProcessor manager = getProcessor(processor);
return manager.getReceiptItem(record.subscriptionId)
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
receipt.itemId(), manager.getProvider(), receiptCredentialRequest,
@ -224,7 +204,7 @@ public class SubscriptionManager {
* <p>
* If the customer does not exist in the table, a customer is created via the subscriptionPaymentProcessor and added
* to the table. Not all payment processors support server-managed customers, so a payment processor that implements
* {@link SubscriptionPaymentProcessor} must be passed in.
* {@link CustomerAwareSubscriptionPaymentProcessor} must be passed in.
*
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
* @param subscriptionPaymentProcessor A customer-aware payment processor to use. If the subscriber already has a
@ -240,7 +220,7 @@ public class SubscriptionManager {
* @return A stage that completes when the payment method has been created in the payment processor and the table has
* been updated
*/
public <T extends SubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
public <T extends CustomerAwareSubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
final SubscriberCredentials subscriberCredentials,
final T subscriptionPaymentProcessor,
final ClientPlatform clientPlatform,
@ -305,7 +285,7 @@ public class SubscriptionManager {
public CompletableFuture<Void> updateSubscriptionLevelForCustomer(
final SubscriberCredentials subscriberCredentials,
final Subscriptions.Record record,
final SubscriptionPaymentProcessor processor,
final CustomerAwareSubscriptionPaymentProcessor processor,
final long level,
final String currency,
final String idempotencyKey,
@ -320,7 +300,7 @@ public class SubscriptionManager {
.getSubscription(subId)
.thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription)
.thenCompose(existingLevelAndCurrency -> {
if (existingLevelAndCurrency.equals(new SubscriptionPaymentProcessor.LevelAndCurrency(level,
if (existingLevelAndCurrency.equals(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level,
currency.toLowerCase(Locale.ROOT)))) {
return CompletableFuture.completedFuture(null);
}
@ -383,7 +363,7 @@ public class SubscriptionManager {
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)
.validateToken(purchaseToken)
// Store the purchaseToken with the subscriber
.thenCompose(validatedToken -> subscriptions.setIapPurchase(
record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now())
@ -394,7 +374,7 @@ public class SubscriptionManager {
}
private Processor getProcessor(PaymentProvider provider) {
private SubscriptionPaymentProcessor getProcessor(PaymentProvider provider) {
return processors.get(provider);
}
}

View File

@ -53,7 +53,7 @@ import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public class BraintreeManager implements SubscriptionPaymentProcessor {
public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
@ -496,10 +496,9 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
final Subscription subscription = getSubscription(subscriptionObj);
return CompletableFuture.supplyAsync(() -> {
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
return getSubscription(subscriptionId).thenApplyAsync(subscriptionObj -> {
final Subscription subscription = getSubscription(subscriptionObj);
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
@ -531,10 +530,12 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
Subscription.Status.ACTIVE == subscription.getStatus(),
!subscription.neverExpires(),
getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed),
PaymentProvider.BRAINTREE,
latestTransaction.map(this::getPaymentMethodFromTransaction).orElse(PaymentMethod.PAYPAL),
paymentProcessing,
chargeFailure
);
}, executor);
}

View File

@ -5,9 +5,51 @@
package org.whispersystems.textsecuregcm.subscriptions;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.annotation.Nullable;
public record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus,
@Nullable String outcomeReason, @Nullable String outcomeType) {
/**
* Information about a charge failure.
* <p>
* This is returned directly from {@link org.whispersystems.textsecuregcm.controllers.SubscriptionController}, so modify
* with care.
*/
@Schema(description = """
Meaningfully interpreting chargeFailure response fields requires inspecting the processor field first.
}
For Stripe, code will be one of the [codes defined here](https://stripe.com/docs/api/charges/object#charge_object-failure_code),
while message [may contain a further textual description](https://stripe.com/docs/api/charges/object#charge_object-failure_message).
The outcome fields are nullable, but present values will directly map to Stripe [response properties](https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status)
For Braintree, the outcome fields will be null. The code and message will contain one of
- a processor decline code (as a string) in code, and associated text in message, as defined this [table](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses)
- `gateway` in code, with a [reason](https://developer.paypal.com/braintree/articles/control-panel/transactions/gateway-rejections) in message
- `code` = "unknown", message = "unknown"
IAP payment processors will never include charge failure information, and detailed order information should be
retrieved from the payment processor directly
""")
public record ChargeFailure(
@Schema(description = """
See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or
[Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)
depending on which processor was used
""")
String code,
@Schema(description = """
See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or
[Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)
depending on which processor was used
""")
String message,
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Network Status", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status"))
@Nullable String outcomeNetworkStatus,
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Reason", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-reason"))
@Nullable String outcomeReason,
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Type", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-type"))
@Nullable String outcomeType) {}

View File

@ -0,0 +1,61 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
/**
* Interface for an external payment provider that has an API-accessible notion of customer that implementations can
* manage. Payment providers that let you add and remove payment methods to an existing customer should implement this
* interface. Contrast this with the super interface {@link SubscriptionPaymentProcessor}, which allows for a payment
* provider with an API that only operations on subscriptions.
*/
public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionPaymentProcessor {
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
/**
* @param customerId
* @param paymentMethodToken a processor-specific token necessary
* @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update
* @return
*/
CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
@Nullable String currentSubscriptionId);
CompletableFuture<Object> getSubscription(String subscriptionId);
CompletableFuture<SubscriptionId> createSubscription(String customerId, String templateId, long level,
long lastSubscriptionCreatedAt);
CompletableFuture<SubscriptionId> updateSubscription(
Object subscription, String templateId, long level, String idempotencyKey);
/**
* @param subscription
* @return the subscriptions current level and lower-case currency code
*/
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
record SubscriptionId(String id) {
}
record LevelAndCurrency(long level, String currency) {
}
}

View File

@ -12,6 +12,9 @@ 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.BasePlan;
import com.google.api.services.androidpublisher.model.OfferDetails;
import com.google.api.services.androidpublisher.model.RegionalBasePlanConfig;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
@ -28,6 +31,7 @@ import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@ -41,7 +45,6 @@ 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;
/**
@ -56,7 +59,7 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
* <li> querying the current status of a token's underlying subscription </li>
* </ul>
*/
public class GooglePlayBillingManager implements SubscriptionManager.Processor {
public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);
@ -218,6 +221,67 @@ public class GooglePlayBillingManager implements SubscriptionManager.Processor {
});
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String purchaseToken) {
final CompletableFuture<SubscriptionPurchaseV2> subscriptionFuture = lookupSubscription(purchaseToken);
final CompletableFuture<SubscriptionPrice> priceFuture = subscriptionFuture.thenCompose(this::getSubscriptionPrice);
return subscriptionFuture.thenCombineAsync(priceFuture, (subscription, price) -> {
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription);
final Optional<Instant> expiration = getExpiration(lineItem);
final SubscriptionStatus status = switch (SubscriptionState
.fromString(subscription.getSubscriptionState())
.orElse(SubscriptionState.UNSPECIFIED)) {
case ACTIVE -> SubscriptionStatus.ACTIVE;
case PENDING -> SubscriptionStatus.INCOMPLETE;
case EXPIRED, ON_HOLD, PAUSED -> SubscriptionStatus.PAST_DUE;
case IN_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
case CANCELED, PENDING_PURCHASE_CANCELED -> SubscriptionStatus.CANCELED;
case UNSPECIFIED -> SubscriptionStatus.UNKNOWN;
};
return new SubscriptionInformation(
price,
productIdToLevel(lineItem.getProductId()),
null, expiration.orElse(null),
expiration.map(clock.instant()::isBefore).orElse(false),
lineItem.getAutoRenewingPlan() != null && lineItem.getAutoRenewingPlan().getAutoRenewEnabled(),
status,
PaymentProvider.GOOGLE_PLAY_BILLING,
PaymentMethod.GOOGLE_PLAY_BILLING,
false,
null);
}, executor);
}
private CompletableFuture<SubscriptionPrice> getSubscriptionPrice(final SubscriptionPurchaseV2 subscriptionPurchase) {
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscriptionPurchase);
final OfferDetails offerDetails = lineItem.getOfferDetails();
final String basePlanId = offerDetails.getBasePlanId();
return this.executeAsync(pub -> pub.monetization().subscriptions().get(packageName, lineItem.getProductId()))
.thenApplyAsync(subscription -> {
final BasePlan basePlan = subscription.getBasePlans().stream()
.filter(bp -> bp.getBasePlanId().equals(basePlanId))
.findFirst()
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown basePlanId " + basePlanId)));
final String region = subscriptionPurchase.getRegionCode();
final RegionalBasePlanConfig basePlanConfig = basePlan.getRegionalConfigs()
.stream()
.filter(rbpc -> Objects.equals(region, rbpc.getRegionCode()))
.findFirst()
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown subscription region " + region)));
return new SubscriptionPrice(
basePlanConfig.getPrice().getCurrencyCode().toUpperCase(Locale.ROOT),
SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(basePlanConfig.getPrice()));
}, executor);
}
@Override
public CompletableFuture<ReceiptItem> getReceiptItem(String purchaseToken) {

View File

@ -77,7 +77,7 @@ import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.util.Conversions;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public class StripeManager implements SubscriptionPaymentProcessor {
public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(StripeManager.class);
private static final String METADATA_KEY_LEVEL = "level";
private static final String METADATA_KEY_CLIENT_PLATFORM = "clientPlatform";
@ -364,6 +364,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
}
@Override
public CompletableFuture<Object> getSubscription(String subscriptionId) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
@ -378,6 +379,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
}, executor);
}
@Override
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
return getCustomer(customerId).thenCompose(customer -> {
if (customer == null) {
@ -536,46 +538,45 @@ public class StripeManager implements SubscriptionPaymentProcessor {
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
return getSubscription(subscriptionId).thenApply(this::getSubscription).thenCompose(subscription ->
getPriceForSubscription(subscription).thenCompose(price ->
getLevelForPrice(price).thenApply(level -> {
ChargeFailure chargeFailure = null;
boolean paymentProcessing = false;
PaymentMethod paymentMethod = null;
final Subscription subscription = getSubscription(subscriptionObj);
if (subscription.getLatestInvoiceObject() != null) {
final Invoice invoice = subscription.getLatestInvoiceObject();
paymentProcessing = "open".equals(invoice.getStatus());
return getPriceForSubscription(subscription).thenCompose(price ->
getLevelForPrice(price).thenApply(level -> {
ChargeFailure chargeFailure = null;
boolean paymentProcessing = false;
PaymentMethod paymentMethod = null;
if (invoice.getChargeObject() != null) {
final Charge charge = invoice.getChargeObject();
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
chargeFailure = createChargeFailure(charge);
}
if (subscription.getLatestInvoiceObject() != null) {
final Invoice invoice = subscription.getLatestInvoiceObject();
paymentProcessing = "open".equals(invoice.getStatus());
if (invoice.getChargeObject() != null) {
final Charge charge = invoice.getChargeObject();
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
chargeFailure = createChargeFailure(charge);
}
if (charge.getPaymentMethodDetails() != null
&& charge.getPaymentMethodDetails().getType() != null) {
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
}
if (charge.getPaymentMethodDetails() != null
&& charge.getPaymentMethodDetails().getType() != null) {
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
}
}
}
return new SubscriptionInformation(
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
level,
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
Objects.equals(subscription.getStatus(), "active"),
subscription.getCancelAtPeriodEnd(),
getSubscriptionStatus(subscription.getStatus()),
paymentMethod,
paymentProcessing,
chargeFailure
);
}));
return new SubscriptionInformation(
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
level,
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
Objects.equals(subscription.getStatus(), "active"),
subscription.getCancelAtPeriodEnd(),
getSubscriptionStatus(subscription.getStatus()),
PaymentProvider.STRIPE,
paymentMethod,
paymentProcessing,
chargeFailure
);
})));
}
private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) {

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.subscriptions;
import com.google.api.services.androidpublisher.model.Money;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Set;
@ -71,4 +72,16 @@ public class SubscriptionCurrencyUtil {
static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) {
return convertConfiguredAmountToApiAmount(currency, amount);
}
/**
* Convert Play Billing's representation of currency amounts to a Stripe-style amount
*
* @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,
* BigDecimal)
*/
static BigDecimal convertGoogleMoneyToApiAmount(final Money money) {
final BigDecimal fractionalComponent = BigDecimal.valueOf(money.getNanos()).scaleByPowerOfTen(-9);
final BigDecimal amount = BigDecimal.valueOf(money.getUnits()).add(fractionalComponent);
return convertConfiguredAmountToApiAmount(money.getCurrencyCode(), amount);
}
}

View File

@ -0,0 +1,21 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import java.time.Instant;
import javax.annotation.Nullable;
public record SubscriptionInformation(
SubscriptionPrice price,
long level,
Instant billingCycleAnchor,
Instant endOfCurrentPeriod,
boolean active,
boolean cancelAtPeriodEnd,
SubscriptionStatus status,
PaymentProvider paymentProvider,
PaymentMethod paymentMethod,
boolean paymentProcessing,
@Nullable ChargeFailure chargeFailure) {}

View File

@ -2,139 +2,42 @@
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Set;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public interface SubscriptionPaymentProcessor extends SubscriptionManager.Processor {
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
public interface SubscriptionPaymentProcessor {
PaymentProvider getProvider();
/**
* @param customerId
* @param paymentMethodToken a processor-specific token necessary
* @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update
* @return
* 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 paymentTime The time this payment was for
* @param level The level which this payment corresponds to
*/
CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
@Nullable String currentSubscriptionId);
CompletableFuture<Object> getSubscription(String subscriptionId);
CompletableFuture<SubscriptionId> createSubscription(String customerId, String templateId, long level,
long lastSubscriptionCreatedAt);
CompletableFuture<SubscriptionId> updateSubscription(
Object subscription, String templateId, long level, String idempotencyKey);
record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
/**
* @param subscription
* @return the subscriptions current level and lower-case currency code
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
*
* @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription
* @return A {@link ReceiptItem} if the subscription is valid
*/
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscription);
enum SubscriptionStatus {
/**
* The subscription is in good standing and the most recent payment was successful.
*/
ACTIVE("active"),
/**
* Payment failed when creating the subscription, or the subscriptions start date is in the future.
*/
INCOMPLETE("incomplete"),
/**
* Payment on the latest renewal failed but there are processor retries left, or payment wasn't attempted.
*/
PAST_DUE("past_due"),
/**
* The subscription has been canceled.
*/
CANCELED("canceled"),
/**
* The latest renewal hasn't been paid but the subscription remains in place.
*/
UNPAID("unpaid"),
/**
* The status from the downstream processor is unknown.
*/
UNKNOWN("unknown");
private final String apiValue;
SubscriptionStatus(String apiValue) {
this.apiValue = apiValue;
}
public static SubscriptionStatus forApiValue(String status) {
return switch (status) {
case "active" -> ACTIVE;
case "canceled", "incomplete_expired" -> CANCELED;
case "unpaid" -> UNPAID;
case "past_due" -> PAST_DUE;
case "incomplete" -> INCOMPLETE;
case "trialing" -> {
final Logger logger = LoggerFactory.getLogger(SubscriptionPaymentProcessor.class);
logger.error("Subscription has status that should never happen: {}", status);
yield UNKNOWN;
}
default -> {
final Logger logger = LoggerFactory.getLogger(SubscriptionPaymentProcessor.class);
logger.error("Subscription has unknown status: {}", status);
yield UNKNOWN;
}
};
}
public String getApiValue() {
return apiValue;
}
}
record SubscriptionId(String id) {
}
record SubscriptionInformation(SubscriptionPrice price, long level, Instant billingCycleAnchor,
Instant endOfCurrentPeriod, boolean active, boolean cancelAtPeriodEnd,
SubscriptionStatus status, PaymentMethod paymentMethod, boolean paymentProcessing,
@Nullable ChargeFailure chargeFailure) {
}
record SubscriptionPrice(String currency, BigDecimal amount) {
}
record LevelAndCurrency(long level, String currency) {
}
/**
* Cancel all active subscriptions for this key within the payment provider.
*
* @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the
* subscriptions table
* @return A stage that completes when all subscriptions associated with the key are cancelled
*/
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId);
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import java.math.BigDecimal;
public record SubscriptionPrice(String currency, BigDecimal amount) {}

View File

@ -0,0 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public enum SubscriptionStatus {
/**
* The subscription is in good standing and the most recent payment was successful.
*/
ACTIVE("active"),
/**
* Payment failed when creating the subscription, or the subscriptions start date is in the future.
*/
INCOMPLETE("incomplete"),
/**
* Payment on the latest renewal failed but there are processor retries left, or payment wasn't attempted.
*/
PAST_DUE("past_due"),
/**
* The subscription has been canceled.
*/
CANCELED("canceled"),
/**
* The latest renewal hasn't been paid but the subscription remains in place.
*/
UNPAID("unpaid"),
/**
* The status from the downstream processor is unknown.
*/
UNKNOWN("unknown");
private final String apiValue;
SubscriptionStatus(String apiValue) {
this.apiValue = apiValue;
}
public static SubscriptionStatus forApiValue(String status) {
return switch (status) {
case "active" -> ACTIVE;
case "canceled", "incomplete_expired" -> CANCELED;
case "unpaid" -> UNPAID;
case "past_due" -> PAST_DUE;
case "incomplete" -> INCOMPLETE;
case "trialing" -> {
final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);
logger.error("Subscription has status that should never happen: {}", status);
yield UNKNOWN;
}
default -> {
final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);
logger.error("Subscription has unknown status: {}", status);
yield UNKNOWN;
}
};
}
public String getApiValue() {
return apiValue;
}
}

View File

@ -33,4 +33,5 @@ public class CompletableFutureUtil {
return completableFuture;
}
}

View File

@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.anyString;
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.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ -93,7 +94,7 @@ import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ -124,7 +125,7 @@ class SubscriptionControllerTest {
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG,
ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS,
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
BANK_MANDATE_TRANSLATOR);
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);
@ -403,7 +404,7 @@ class SubscriptionControllerTest {
@Test
void createSubscriptionSuccess() {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(mock(SubscriptionPaymentProcessor.SubscriptionId.class)));
.thenReturn(CompletableFuture.completedFuture(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class)));
final String level = String.valueOf(levelId);
final String idempotencyKey = UUID.randomUUID().toString();
@ -715,7 +716,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId(
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
"subscription")));
when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(null));
@ -769,12 +770,12 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture(
new SubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
final String updatedSubscriptionId = "updatedSubscriptionId";
if (expectUpdate) {
when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId(
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
updatedSubscriptionId)));
when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(null));
@ -844,7 +845,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture(
new SubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")));
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")));
// Try to change from a backup subscription (201) to a donation subscription (5)
final Response response = RESOURCE_EXTENSION
@ -860,6 +861,50 @@ class SubscriptionControllerTest {
.isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL);
}
@Test
public void setPlayPurchaseToken() {
final String purchaseToken = "aPurchaseToken";
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16);
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
final Instant now = Instant.now();
when(CLOCK.instant()).thenReturn(now);
final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),
Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())
);
final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem);
when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);
when(validatedToken.getLevel()).thenReturn(99L);
when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null));
when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken));
when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
final Response response = RESOURCE_EXTENSION
.target(String.format("/v1/subscription/%s/playbilling/%s", subscriberId, purchaseToken))
.request()
.post(Entity.json(""));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level())
.isEqualTo(99L);
verify(SUBSCRIPTIONS, times(1)).setIapPurchase(
any(),
eq(new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING)),
eq(purchaseToken),
eq(99L),
eq(now));
}
@ParameterizedTest
@CsvSource({"5, P45D", "201, P13D"})
public void createReceiptCredential(long level, Duration expectedExpirationWindow)
@ -887,7 +932,7 @@ class SubscriptionControllerTest {
when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(
"itemId",
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
level

View File

@ -16,9 +16,15 @@ 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.BasePlan;
import com.google.api.services.androidpublisher.model.Money;
import com.google.api.services.androidpublisher.model.OfferDetails;
import com.google.api.services.androidpublisher.model.RegionalBasePlanConfig;
import com.google.api.services.androidpublisher.model.Subscription;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@ -32,7 +38,6 @@ 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;
@ -56,6 +61,10 @@ class GooglePlayBillingManagerTest {
private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =
mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class);
// Returned in response to a monetization.subscriptions.get
private final AndroidPublisher.Monetization.Subscriptions.Get subscriptionConfig =
mock(AndroidPublisher.Monetization.Subscriptions.Get.class);
private final MutableClock clock = MockUtils.mutableClock(0L);
private ExecutorService executor;
@ -68,9 +77,12 @@ class GooglePlayBillingManagerTest {
AndroidPublisher androidPublisher = mock(AndroidPublisher.class);
AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.class);
AndroidPublisher.Monetization monetization = mock(AndroidPublisher.Monetization.class);
when(androidPublisher.purchases()).thenReturn(purchases);
when(androidPublisher.monetization()).thenReturn(monetization);
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);
@ -81,6 +93,11 @@ class GooglePlayBillingManagerTest {
when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))
.thenReturn(cancel);
AndroidPublisher.Monetization.Subscriptions msubscriptions = mock(
AndroidPublisher.Monetization.Subscriptions.class);
when(monetization.subscriptions()).thenReturn(msubscriptions);
when(msubscriptions.get(PACKAGE_NAME, PRODUCT_ID)).thenReturn(subscriptionConfig);
executor = Executors.newSingleThreadExecutor();
googlePlayBillingManager = new GooglePlayBillingManager(
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
@ -186,7 +203,7 @@ class GooglePlayBillingManagerTest {
.setProductId(PRODUCT_ID))));
clock.setTimeInstant(day9);
SubscriptionManager.Processor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
assertThat(item.itemId()).isEqualTo(ORDER_ID);
assertThat(item.level()).isEqualTo(201L);
@ -207,4 +224,33 @@ class GooglePlayBillingManagerTest {
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
}
@Test
public void getSubscriptionInfo() throws IOException {
final String basePlanId = "basePlanId";
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
.setLatestOrderId(ORDER_ID)
.setRegionCode("US")
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
.setProductId(PRODUCT_ID)
.setOfferDetails(new OfferDetails().setBasePlanId(basePlanId)))));
final BasePlan basePlan = new BasePlan()
.setBasePlanId(basePlanId)
.setRegionalConfigs(List.of(
new RegionalBasePlanConfig()
.setRegionCode("US")
.setPrice(new Money().setCurrencyCode("USD").setUnits(1L).setNanos(750_000_000))));
when(subscriptionConfig.execute()).thenReturn(new Subscription().setBasePlans(List.of(basePlan)));
final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN).join();
assertThat(info.active()).isTrue();
assertThat(info.paymentProcessing()).isFalse();
assertThat(info.price().currency()).isEqualTo("USD");
assertThat(info.price().amount().compareTo(new BigDecimal("175"))).isEqualTo(0); // 175 cents
assertThat(info.level()).isEqualTo(201L);
}
}