diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index f76a448d3..333fbe971 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -1142,8 +1142,8 @@ public class WhisperServerService extends Application buildCurrencyConfiguration() { - final List subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager); + final List 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 setPlayStoreSubscription( + @ReadOnly @Auth Optional 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 [Stripe’s 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 don’t 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 getSubscriptionInformation( @ReadOnly @Auth Optional 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 setDefaultPaymentMethod(final SubscriptionPaymentProcessor manager, + private CompletableFuture 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( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java index 4b4f9adca..0e0903147 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java @@ -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 processors; + private final EnumMap processors; private final ServerZkReceiptOperations zkReceiptOperations; private final IssuedReceiptsManager issuedReceiptsManager; public SubscriptionManager( @Nonnull Subscriptions subscriptions, - @Nonnull List processors, + @Nonnull List 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 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 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> 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 createReceiptCredentials( final SubscriberCredentials subscriberCredentials, final SubscriptionController.GetReceiptCredentialsRequest request, - final Function expiration) { + final Function 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 { *

* 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 CompletableFuture addPaymentMethodToCustomer( + public CompletableFuture addPaymentMethodToCustomer( final SubscriberCredentials subscriberCredentials, final T subscriptionPaymentProcessor, final ClientPlatform clientPlatform, @@ -305,7 +285,7 @@ public class SubscriptionManager { public CompletableFuture 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); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index f70eace42..8d6ea2857 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -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 getSubscriptionInformation(Object subscriptionObj) { - final Subscription subscription = getSubscription(subscriptionObj); - - return CompletableFuture.supplyAsync(() -> { + public CompletableFuture 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); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java index 55b56bd2a..3911ef03b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java @@ -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. + *

+ * 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) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java new file mode 100644 index 000000000..2e853f07a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java @@ -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 getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod); + + CompletableFuture createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform); + + CompletableFuture 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 setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, + @Nullable String currentSubscriptionId); + + CompletableFuture getSubscription(String subscriptionId); + + CompletableFuture createSubscription(String customerId, String templateId, long level, + long lastSubscriptionCreatedAt); + + CompletableFuture updateSubscription( + Object subscription, String templateId, long level, String idempotencyKey); + + /** + * @param subscription + * @return the subscription’s current level and lower-case currency code + */ + CompletableFuture getLevelAndCurrencyForSubscription(Object subscription); + + record SubscriptionId(String id) { + + } + + record LevelAndCurrency(long level, String currency) { + + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java index 63b25fc27..23eddb558 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java @@ -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; *
  • querying the current status of a token's underlying subscription
  • * */ -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 getSubscriptionInformation(final String purchaseToken) { + + final CompletableFuture subscriptionFuture = lookupSubscription(purchaseToken); + final CompletableFuture priceFuture = subscriptionFuture.thenCompose(this::getSubscriptionPrice); + + return subscriptionFuture.thenCombineAsync(priceFuture, (subscription, price) -> { + + final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription); + final Optional 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 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 getReceiptItem(String purchaseToken) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index 42c10e648..2414a8b51 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -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 getSubscription(String subscriptionId) { return CompletableFuture.supplyAsync(() -> { SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() @@ -378,6 +379,7 @@ public class StripeManager implements SubscriptionPaymentProcessor { }, executor); } + @Override public CompletableFuture cancelAllActiveSubscriptions(String customerId) { return getCustomer(customerId).thenCompose(customer -> { if (customer == null) { @@ -536,46 +538,45 @@ public class StripeManager implements SubscriptionPaymentProcessor { } @Override - public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { + public CompletableFuture 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) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java index d3c943cac..9838be3dc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java @@ -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); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInformation.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInformation.java new file mode 100644 index 000000000..ecd200e01 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInformation.java @@ -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) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java index ff03eddef..93440d844 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java @@ -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 getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod); - - CompletableFuture createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform); - - CompletableFuture 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 setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, - @Nullable String currentSubscriptionId); - - CompletableFuture getSubscription(String subscriptionId); - - CompletableFuture createSubscription(String customerId, String templateId, long level, - long lastSubscriptionCreatedAt); - - CompletableFuture updateSubscription( - Object subscription, String templateId, long level, String idempotencyKey); + record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {} /** - * @param subscription - * @return the subscription’s 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 getLevelAndCurrencyForSubscription(Object subscription); + CompletableFuture getReceiptItem(String subscriptionId); - CompletableFuture 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 subscription’s 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 cancelAllActiveSubscriptions(String key); + CompletableFuture getSubscriptionInformation(final String subscriptionId); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPrice.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPrice.java new file mode 100644 index 000000000..a8edd7bc7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPrice.java @@ -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) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionStatus.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionStatus.java new file mode 100644 index 000000000..72ea6b003 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionStatus.java @@ -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 subscription’s 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; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java index 3d7f79466..ef5e38b97 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java @@ -33,4 +33,5 @@ public class CompletableFutureUtil { return completableFuture; } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 6c04af602..e373c5cf6 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -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 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 diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java index e8cf23a0a..58fc7986c 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java @@ -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); + } + }