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), List.of(stripeManager, braintreeManager, googlePlayBillingManager),
zkReceiptOperations, issuedReceiptsManager); zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(), commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator, subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
bankMandateTranslator)); profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager, commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager)); zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
} }

View File

@ -47,7 +47,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; 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.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; 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.ExactlySize;
import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; 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 * @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/ */
private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount, private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount,
SubscriptionPaymentProcessor manager) { CustomerAwareSubscriptionPaymentProcessor manager) {
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod) if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
.contains(request.currency.toLowerCase(Locale.ROOT))) { .contains(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) 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.Metrics;
import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags; 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.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; 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.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; 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.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@ -102,6 +104,7 @@ public class SubscriptionController {
private final SubscriptionManager subscriptionManager; private final SubscriptionManager subscriptionManager;
private final StripeManager stripeManager; private final StripeManager stripeManager;
private final BraintreeManager braintreeManager; private final BraintreeManager braintreeManager;
private final GooglePlayBillingManager googlePlayBillingManager;
private final BadgeTranslator badgeTranslator; private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator; private final LevelTranslator levelTranslator;
private final BankMandateTranslator bankMandateTranslator; private final BankMandateTranslator bankMandateTranslator;
@ -117,6 +120,7 @@ public class SubscriptionController {
@Nonnull SubscriptionManager subscriptionManager, @Nonnull SubscriptionManager subscriptionManager,
@Nonnull StripeManager stripeManager, @Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager, @Nonnull BraintreeManager braintreeManager,
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
@Nonnull BadgeTranslator badgeTranslator, @Nonnull BadgeTranslator badgeTranslator,
@Nonnull LevelTranslator levelTranslator, @Nonnull LevelTranslator levelTranslator,
@Nonnull BankMandateTranslator bankMandateTranslator) { @Nonnull BankMandateTranslator bankMandateTranslator) {
@ -126,13 +130,14 @@ public class SubscriptionController {
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager); this.stripeManager = Objects.requireNonNull(stripeManager);
this.braintreeManager = Objects.requireNonNull(braintreeManager); this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator); this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator); this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator); this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
} }
private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() { private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {
final List<SubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager); final List<CustomerAwareSubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
return oneTimeDonationConfiguration.currencies() return oneTimeDonationConfiguration.currencies()
.entrySet().stream() .entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> { .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
@ -252,7 +257,7 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials = SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); 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 // 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. // other types (like CARD) in the future.
case CARD, SEPA_DEBIT, IDEAL -> stripeManager; case CARD, SEPA_DEBIT, IDEAL -> stripeManager;
@ -264,11 +269,11 @@ public class SubscriptionController {
return subscriptionManager.addPaymentMethodToCustomer( return subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials, subscriberCredentials,
subscriptionPaymentProcessor, customerAwareSubscriptionPaymentProcessor,
getClientPlatform(userAgentString), getClientPlatform(userAgentString),
SubscriptionPaymentProcessor::createPaymentMethodSetupToken) CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken)
.thenApply(token -> .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) {} public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {}
@ -306,7 +311,7 @@ public class SubscriptionController {
.build()); .build());
} }
private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) { private CustomerAwareSubscriptionPaymentProcessor getCustomerAwareProcessor(PaymentProvider processor) {
return switch (processor) { return switch (processor) {
case STRIPE -> stripeManager; case STRIPE -> stripeManager;
case BRAINTREE -> braintreeManager; case BRAINTREE -> braintreeManager;
@ -326,7 +331,7 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials = SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processor); final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processor);
return setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials); return setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);
} }
@ -369,7 +374,7 @@ public class SubscriptionController {
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,
processorCustomer.processor()); processorCustomer.processor());
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processorCustomer.processor()); final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processorCustomer.processor());
return subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level, return subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,
currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType); currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);
}) })
@ -395,6 +400,43 @@ public class SubscriptionController {
== subscriptionConfiguration.getSubscriptionLevel(level2).type(); == 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 = """ @Schema(description = """
Comprehensive configuration for donation subscriptions, backup subscriptions, gift subscriptions, and one-time 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 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 GetBankMandateResponse(String mandate) {}
public record GetSubscriptionInformationResponse( public record GetSubscriptionInformationResponse(
@Schema(description = "Information about the subscription, or null if no subscription is present")
SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription, SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,
@Schema(description = "May be omitted entirely if no charge failure is detected")
@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) { @JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {
public record Subscription(long level, Instant billingCycleAnchor, Instant endOfCurrentPeriod, boolean active, public record Subscription(
boolean cancelAtPeriodEnd, String currency, BigDecimal amount, String status, @Schema(description = "The subscription level")
PaymentProvider processor, PaymentMethod paymentMethod, boolean paymentProcessing) { 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 @GET
@Path("/{subscriberId}") @Path("/{subscriberId}")
@Produces(MediaType.APPLICATION_JSON) @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( public CompletableFuture<Response> getSubscriptionInformation(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount, @ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException { @PathParam("subscriberId") String subscriberId) throws SubscriptionException {
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); SubscriberCredentials subscriberCredentials =
return subscriptionManager.getSubscriber(subscriberCredentials) SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
.thenCompose(record -> { return subscriptionManager.getSubscriptionInformation(subscriberCredentials).thenApply(maybeInfo -> maybeInfo
if (record.subscriptionId == null) { .map(subscriptionInformation -> Response.ok(
return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build()); new GetSubscriptionInformationResponse(
} new GetSubscriptionInformationResponse.Subscription(
subscriptionInformation.level(),
final SubscriptionPaymentProcessor manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); subscriptionInformation.billingCycleAnchor(),
subscriptionInformation.endOfCurrentPeriod(),
return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> subscriptionInformation.active(),
manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> Response.ok( subscriptionInformation.cancelAtPeriodEnd(),
new GetSubscriptionInformationResponse( subscriptionInformation.price().currency(),
new GetSubscriptionInformationResponse.Subscription( subscriptionInformation.price().amount(),
subscriptionInformation.level(), subscriptionInformation.status().getApiValue(),
subscriptionInformation.billingCycleAnchor(), subscriptionInformation.paymentProvider(),
subscriptionInformation.endOfCurrentPeriod(), subscriptionInformation.paymentMethod(),
subscriptionInformation.active(), subscriptionInformation.paymentProcessing()),
subscriptionInformation.cancelAtPeriodEnd(), subscriptionInformation.chargeFailure()
subscriptionInformation.price().currency(), )).build())
subscriptionInformation.price().amount(), .orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
subscriptionInformation.status().getApiValue(),
manager.getProvider(),
subscriptionInformation.paymentMethod(),
subscriptionInformation.paymentProcessing()),
subscriptionInformation.chargeFailure()
)).build()));
});
} }
public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) { public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) {
@ -536,7 +618,7 @@ public class SubscriptionController {
return subscriptionManager.createReceiptCredentials(subscriberCredentials, request, this::receiptExpirationWithGracePeriod) return subscriptionManager.createReceiptCredentials(subscriberCredentials, request, this::receiptExpirationWithGracePeriod)
.thenApply(receiptCredential -> { .thenApply(receiptCredential -> {
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse(); final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final SubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem(); final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME, Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of( Tags.of(
Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()), Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),
@ -564,7 +646,7 @@ public class SubscriptionController {
.thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials)); .thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials));
} }
private CompletableFuture<Response> setDefaultPaymentMethod(final SubscriptionPaymentProcessor manager, private CompletableFuture<Response> setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,
final String paymentMethodId, final String paymentMethodId,
final SubscriberCredentials requestData) { final SubscriberCredentials requestData) {
return subscriptionManager.getSubscriber(requestData) return subscriptionManager.getSubscriber(requestData)
@ -578,7 +660,7 @@ public class SubscriptionController {
.thenApply(customer -> Response.ok().build()); .thenApply(customer -> Response.ok().build());
} }
private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) { private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {
final PaymentTime paymentTime = receiptItem.paymentTime(); final PaymentTime paymentTime = receiptItem.paymentTime();
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) { return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
case DONATION -> paymentTime.receiptExpiration( 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.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController; import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@ -41,54 +43,22 @@ import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public class SubscriptionManager { public class SubscriptionManager {
private final Subscriptions subscriptions; private final Subscriptions subscriptions;
private final EnumMap<PaymentProvider, Processor> processors; private final EnumMap<PaymentProvider, SubscriptionPaymentProcessor> processors;
private final ServerZkReceiptOperations zkReceiptOperations; private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager; private final IssuedReceiptsManager issuedReceiptsManager;
public SubscriptionManager( public SubscriptionManager(
@Nonnull Subscriptions subscriptions, @Nonnull Subscriptions subscriptions,
@Nonnull List<Processor> processors, @Nonnull List<SubscriptionPaymentProcessor> processors,
@Nonnull ServerZkReceiptOperations zkReceiptOperations, @Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager) { @Nonnull IssuedReceiptsManager issuedReceiptsManager) {
this.subscriptions = Objects.requireNonNull(subscriptions); this.subscriptions = Objects.requireNonNull(subscriptions);
this.processors = new EnumMap<>(processors.stream() 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.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); 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 * 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); .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 * Get the subscriber record
* *
@ -167,7 +147,7 @@ public class SubscriptionManager {
public record ReceiptResult( public record ReceiptResult(
ReceiptCredentialResponse receiptCredentialResponse, ReceiptCredentialResponse receiptCredentialResponse,
SubscriptionPaymentProcessor.ReceiptItem receiptItem, CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem,
PaymentProvider paymentProvider) {} PaymentProvider paymentProvider) {}
/** /**
@ -175,14 +155,14 @@ public class SubscriptionManager {
* *
* @param subscriberCredentials Subscriber credentials derived from the subscriberId * @param subscriberCredentials Subscriber credentials derived from the subscriberId
* @param request The ZK Receipt credential request * @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 * the expiration time of the receipt
* @return If the subscription had a valid payment, the requested ZK receipt credential * @return If the subscription had a valid payment, the requested ZK receipt credential
*/ */
public CompletableFuture<ReceiptResult> createReceiptCredentials( public CompletableFuture<ReceiptResult> createReceiptCredentials(
final SubscriberCredentials subscriberCredentials, final SubscriberCredentials subscriberCredentials,
final SubscriptionController.GetReceiptCredentialsRequest request, final SubscriptionController.GetReceiptCredentialsRequest request,
final Function<SubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) { final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
return getSubscriber(subscriberCredentials).thenCompose(record -> { return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) { if (record.subscriptionId == null) {
return CompletableFuture.failedFuture(new SubscriptionException.NotFound()); return CompletableFuture.failedFuture(new SubscriptionException.NotFound());
@ -197,7 +177,7 @@ public class SubscriptionManager {
} }
final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor(); final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();
final Processor manager = getProcessor(processor); final SubscriptionPaymentProcessor manager = getProcessor(processor);
return manager.getReceiptItem(record.subscriptionId) return manager.getReceiptItem(record.subscriptionId)
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance( .thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
receipt.itemId(), manager.getProvider(), receiptCredentialRequest, receipt.itemId(), manager.getProvider(), receiptCredentialRequest,
@ -224,7 +204,7 @@ public class SubscriptionManager {
* <p> * <p>
* If the customer does not exist in the table, a customer is created via the subscriptionPaymentProcessor and added * 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 * 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 subscriberCredentials Subscriber credentials derived from the subscriberId
* @param subscriptionPaymentProcessor A customer-aware payment processor to use. If the subscriber already has a * @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 * @return A stage that completes when the payment method has been created in the payment processor and the table has
* been updated * been updated
*/ */
public <T extends SubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer( public <T extends CustomerAwareSubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
final SubscriberCredentials subscriberCredentials, final SubscriberCredentials subscriberCredentials,
final T subscriptionPaymentProcessor, final T subscriptionPaymentProcessor,
final ClientPlatform clientPlatform, final ClientPlatform clientPlatform,
@ -305,7 +285,7 @@ public class SubscriptionManager {
public CompletableFuture<Void> updateSubscriptionLevelForCustomer( public CompletableFuture<Void> updateSubscriptionLevelForCustomer(
final SubscriberCredentials subscriberCredentials, final SubscriberCredentials subscriberCredentials,
final Subscriptions.Record record, final Subscriptions.Record record,
final SubscriptionPaymentProcessor processor, final CustomerAwareSubscriptionPaymentProcessor processor,
final long level, final long level,
final String currency, final String currency,
final String idempotencyKey, final String idempotencyKey,
@ -320,7 +300,7 @@ public class SubscriptionManager {
.getSubscription(subId) .getSubscription(subId)
.thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription) .thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription)
.thenCompose(existingLevelAndCurrency -> { .thenCompose(existingLevelAndCurrency -> {
if (existingLevelAndCurrency.equals(new SubscriptionPaymentProcessor.LevelAndCurrency(level, if (existingLevelAndCurrency.equals(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level,
currency.toLowerCase(Locale.ROOT)))) { currency.toLowerCase(Locale.ROOT)))) {
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }
@ -383,7 +363,7 @@ public class SubscriptionManager {
return googlePlayBillingManager return googlePlayBillingManager
// Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager, // 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. // but we don't want to acknowledge it until it's successfully persisted.
.validateToken(record.subscriptionId) .validateToken(purchaseToken)
// Store the purchaseToken with the subscriber // Store the purchaseToken with the subscriber
.thenCompose(validatedToken -> subscriptions.setIapPurchase( .thenCompose(validatedToken -> subscriptions.setIapPurchase(
record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now()) 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); 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.SystemMapper;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; 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); private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
@ -496,10 +496,9 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
} }
@Override @Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) { public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
final Subscription subscription = getSubscription(subscriptionObj); return getSubscription(subscriptionId).thenApplyAsync(subscriptionObj -> {
final Subscription subscription = getSubscription(subscriptionObj);
return CompletableFuture.supplyAsync(() -> {
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId()); final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
@ -531,10 +530,12 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
Subscription.Status.ACTIVE == subscription.getStatus(), Subscription.Status.ACTIVE == subscription.getStatus(),
!subscription.neverExpires(), !subscription.neverExpires(),
getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed), getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed),
PaymentProvider.BRAINTREE,
latestTransaction.map(this::getPaymentMethodFromTransaction).orElse(PaymentMethod.PAYPAL), latestTransaction.map(this::getPaymentMethodFromTransaction).orElse(PaymentMethod.PAYPAL),
paymentProcessing, paymentProcessing,
chargeFailure chargeFailure
); );
}, executor); }, executor);
} }

View File

@ -5,9 +5,51 @@
package org.whispersystems.textsecuregcm.subscriptions; package org.whispersystems.textsecuregcm.subscriptions;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.annotation.Nullable; 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.AndroidPublisher;
import com.google.api.services.androidpublisher.AndroidPublisherRequest; import com.google.api.services.androidpublisher.AndroidPublisherRequest;
import com.google.api.services.androidpublisher.AndroidPublisherScopes; 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.SubscriptionPurchaseLineItem;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2; import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest; import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
@ -28,6 +31,7 @@ import java.time.Instant;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@ -41,7 +45,6 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime; import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; 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> * <li> querying the current status of a token's underlying subscription </li>
* </ul> * </ul>
*/ */
public class GooglePlayBillingManager implements SubscriptionManager.Processor { public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class); 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 @Override
public CompletableFuture<ReceiptItem> getReceiptItem(String purchaseToken) { 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.Conversions;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; 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 Logger logger = LoggerFactory.getLogger(StripeManager.class);
private static final String METADATA_KEY_LEVEL = "level"; private static final String METADATA_KEY_LEVEL = "level";
private static final String METADATA_KEY_CLIENT_PLATFORM = "clientPlatform"; private static final String METADATA_KEY_CLIENT_PLATFORM = "clientPlatform";
@ -364,6 +364,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId())); .thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
} }
@Override
public CompletableFuture<Object> getSubscription(String subscriptionId) { public CompletableFuture<Object> getSubscription(String subscriptionId) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
@ -378,6 +379,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
}, executor); }, executor);
} }
@Override
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) { public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
return getCustomer(customerId).thenCompose(customer -> { return getCustomer(customerId).thenCompose(customer -> {
if (customer == null) { if (customer == null) {
@ -536,46 +538,45 @@ public class StripeManager implements SubscriptionPaymentProcessor {
} }
@Override @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 -> if (invoice.getChargeObject() != null) {
getLevelForPrice(price).thenApply(level -> { final Charge charge = invoice.getChargeObject();
ChargeFailure chargeFailure = null; if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
boolean paymentProcessing = false; chargeFailure = createChargeFailure(charge);
PaymentMethod paymentMethod = null; }
if (subscription.getLatestInvoiceObject() != null) { if (charge.getPaymentMethodDetails() != null
final Invoice invoice = subscription.getLatestInvoiceObject(); && charge.getPaymentMethodDetails().getType() != null) {
paymentProcessing = "open".equals(invoice.getStatus()); paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
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());
}
} }
} }
}
return new SubscriptionInformation( return new SubscriptionInformation(
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()), new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
level, level,
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()), Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()), Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
Objects.equals(subscription.getStatus(), "active"), Objects.equals(subscription.getStatus(), "active"),
subscription.getCancelAtPeriodEnd(), subscription.getCancelAtPeriodEnd(),
getSubscriptionStatus(subscription.getStatus()), getSubscriptionStatus(subscription.getStatus()),
paymentMethod, PaymentProvider.STRIPE,
paymentProcessing, paymentMethod,
chargeFailure paymentProcessing,
); chargeFailure
})); );
})));
} }
private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) { private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) {

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.subscriptions; package org.whispersystems.textsecuregcm.subscriptions;
import com.google.api.services.androidpublisher.model.Money;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
@ -71,4 +72,16 @@ public class SubscriptionCurrencyUtil {
static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) { static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) {
return convertConfiguredAmountToApiAmount(currency, 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 * Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.whispersystems.textsecuregcm.subscriptions; package org.whispersystems.textsecuregcm.subscriptions;
import java.math.BigDecimal; import org.whispersystems.textsecuregcm.storage.PaymentTime;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.CompletableFuture; 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 { public interface SubscriptionPaymentProcessor {
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
PaymentProvider getProvider();
/** /**
* @param customerId * A receipt of payment from a payment provider
* @param paymentMethodToken a processor-specific token necessary *
* @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update * @param itemId An identifier for the payment that should be unique within the payment provider. Note that this
* @return * 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, record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
@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 * Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
* @return the subscriptions current level and lower-case currency code *
* @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); /**
* Cancel all active subscriptions for this key within the payment provider.
enum SubscriptionStatus { *
/** * @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the
* The subscription is in good standing and the most recent payment was successful. * subscriptions table
*/ * @return A stage that completes when all subscriptions associated with the key are cancelled
ACTIVE("active"), */
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
/**
* 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) {
}
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; return completableFuture;
} }
} }

View File

@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset; import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when; 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.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; 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.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper; import org.whispersystems.textsecuregcm.util.SystemMapper;
@ -124,7 +125,7 @@ class SubscriptionControllerTest {
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG, private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG,
ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS, ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS,
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
BANK_MANDATE_TRANSLATOR); BANK_MANDATE_TRANSLATOR);
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK, private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER); ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);
@ -403,7 +404,7 @@ class SubscriptionControllerTest {
@Test @Test
void createSubscriptionSuccess() { void createSubscriptionSuccess() {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) 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 level = String.valueOf(levelId);
final String idempotencyKey = UUID.randomUUID().toString(); final String idempotencyKey = UUID.randomUUID().toString();
@ -715,7 +716,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId( .thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
"subscription"))); "subscription")));
when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong())) when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));
@ -769,12 +770,12 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(subscriptionObj)); .thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture( .thenReturn(CompletableFuture.completedFuture(
new SubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency))); new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
final String updatedSubscriptionId = "updatedSubscriptionId"; final String updatedSubscriptionId = "updatedSubscriptionId";
if (expectUpdate) { if (expectUpdate) {
when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString())) when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId( .thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
updatedSubscriptionId))); updatedSubscriptionId)));
when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString())) when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));
@ -844,7 +845,7 @@ class SubscriptionControllerTest {
.thenReturn(CompletableFuture.completedFuture(subscriptionObj)); .thenReturn(CompletableFuture.completedFuture(subscriptionObj));
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
.thenReturn(CompletableFuture.completedFuture( .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) // Try to change from a backup subscription (201) to a donation subscription (5)
final Response response = RESOURCE_EXTENSION final Response response = RESOURCE_EXTENSION
@ -860,6 +861,50 @@ class SubscriptionControllerTest {
.isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL); .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 @ParameterizedTest
@CsvSource({"5, P45D", "201, P13D"}) @CsvSource({"5, P45D", "201, P13D"})
public void createReceiptCredential(long level, Duration expectedExpirationWindow) public void createReceiptCredential(long level, Duration expectedExpirationWindow)
@ -887,7 +932,7 @@ class SubscriptionControllerTest {
when(SUBSCRIPTIONS.get(any(), any())) when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn( when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem( CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(
"itemId", "itemId",
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))), PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
level level

View File

@ -16,9 +16,15 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.google.api.services.androidpublisher.AndroidPublisher; 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.SubscriptionPurchaseLineItem;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2; import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; 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.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.EnumSource;
import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock; import org.whispersystems.textsecuregcm.util.MutableClock;
@ -56,6 +61,10 @@ class GooglePlayBillingManagerTest {
private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel = private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =
mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class); 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 final MutableClock clock = MockUtils.mutableClock(0L);
private ExecutorService executor; private ExecutorService executor;
@ -68,9 +77,12 @@ class GooglePlayBillingManagerTest {
AndroidPublisher androidPublisher = mock(AndroidPublisher.class); AndroidPublisher androidPublisher = mock(AndroidPublisher.class);
AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.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); AndroidPublisher.Purchases.Subscriptionsv2 subscriptionsv2 = mock(AndroidPublisher.Purchases.Subscriptionsv2.class);
when(androidPublisher.purchases()).thenReturn(purchases);
when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2); when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2);
when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get); when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get);
@ -81,6 +93,11 @@ class GooglePlayBillingManagerTest {
when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN)) when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))
.thenReturn(cancel); .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(); executor = Executors.newSingleThreadExecutor();
googlePlayBillingManager = new GooglePlayBillingManager( googlePlayBillingManager = new GooglePlayBillingManager(
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor); androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
@ -186,7 +203,7 @@ class GooglePlayBillingManagerTest {
.setProductId(PRODUCT_ID)))); .setProductId(PRODUCT_ID))));
clock.setTimeInstant(day9); 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.itemId()).isEqualTo(ORDER_ID);
assertThat(item.level()).isEqualTo(201L); assertThat(item.level()).isEqualTo(201L);
@ -207,4 +224,33 @@ class GooglePlayBillingManagerTest {
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN)); 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);
}
} }