Add `playbilling` endpoint to `/v1/subscriptions`
This commit is contained in:
		
							parent
							
								
									3b4d445ca8
								
							
						
					
					
						commit
						564dba3053
					
				|  | @ -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)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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,33 +514,73 @@ 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 [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<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()); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             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( | ||||||
|                 new GetSubscriptionInformationResponse.Subscription( |                 new GetSubscriptionInformationResponse.Subscription( | ||||||
|                     subscriptionInformation.level(), |                     subscriptionInformation.level(), | ||||||
|  | @ -509,12 +591,12 @@ public class SubscriptionController { | ||||||
|                     subscriptionInformation.price().currency(), |                     subscriptionInformation.price().currency(), | ||||||
|                     subscriptionInformation.price().amount(), |                     subscriptionInformation.price().amount(), | ||||||
|                     subscriptionInformation.status().getApiValue(), |                     subscriptionInformation.status().getApiValue(), | ||||||
|                             manager.getProvider(), |                     subscriptionInformation.paymentProvider(), | ||||||
|                     subscriptionInformation.paymentMethod(), |                     subscriptionInformation.paymentMethod(), | ||||||
|                     subscriptionInformation.paymentProcessing()), |                     subscriptionInformation.paymentProcessing()), | ||||||
|                 subscriptionInformation.chargeFailure() |                 subscriptionInformation.chargeFailure() | ||||||
|                     )).build())); |             )).build()) | ||||||
|         }); |         .orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).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( | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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,11 +496,10 @@ public class BraintreeManager implements SubscriptionPaymentProcessor { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Override |   @Override | ||||||
|   public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) { |   public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) { | ||||||
|  |     return getSubscription(subscriptionId).thenApplyAsync(subscriptionObj -> { | ||||||
|       final Subscription subscription = getSubscription(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()); | ||||||
| 
 | 
 | ||||||
|       final long level = getLevelForPlan(plan); |       final long level = getLevelForPlan(plan); | ||||||
|  | @ -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); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) {} | ||||||
|  |  | ||||||
|  | @ -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 subscription’s current level and lower-case currency code | ||||||
|  |    */ | ||||||
|  |   CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription); | ||||||
|  | 
 | ||||||
|  |   record SubscriptionId(String id) { | ||||||
|  | 
 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   record LevelAndCurrency(long level, String currency) { | ||||||
|  | 
 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -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) { | ||||||
|  |  | ||||||
|  | @ -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,11 +538,9 @@ 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 -> | ||||||
|     final Subscription subscription = getSubscription(subscriptionObj); |         getPriceForSubscription(subscription).thenCompose(price -> | ||||||
| 
 |  | ||||||
|     return getPriceForSubscription(subscription).thenCompose(price -> |  | ||||||
|           getLevelForPrice(price).thenApply(level -> { |           getLevelForPrice(price).thenApply(level -> { | ||||||
|             ChargeFailure chargeFailure = null; |             ChargeFailure chargeFailure = null; | ||||||
|             boolean paymentProcessing = false; |             boolean paymentProcessing = false; | ||||||
|  | @ -571,11 +571,12 @@ public class StripeManager implements SubscriptionPaymentProcessor { | ||||||
|                 Objects.equals(subscription.getStatus(), "active"), |                 Objects.equals(subscription.getStatus(), "active"), | ||||||
|                 subscription.getCancelAtPeriodEnd(), |                 subscription.getCancelAtPeriodEnd(), | ||||||
|                 getSubscriptionStatus(subscription.getStatus()), |                 getSubscriptionStatus(subscription.getStatus()), | ||||||
|  |                 PaymentProvider.STRIPE, | ||||||
|                 paymentMethod, |                 paymentMethod, | ||||||
|                 paymentProcessing, |                 paymentProcessing, | ||||||
|                 chargeFailure |                 chargeFailure | ||||||
|             ); |             ); | ||||||
|             })); |           }))); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) { |   private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) { | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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) {} | ||||||
|  | @ -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 subscription’s 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); |  | ||||||
| 
 |  | ||||||
|   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. |    * 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 | ||||||
|    */ |    */ | ||||||
|     INCOMPLETE("incomplete"), |   CompletableFuture<Void> cancelAllActiveSubscriptions(String key); | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * 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); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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) {} | ||||||
|  | @ -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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -33,4 +33,5 @@ public class CompletableFutureUtil { | ||||||
| 
 | 
 | ||||||
|     return completableFuture; |     return completableFuture; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 ravi-signal
						ravi-signal