diff --git a/pom.xml b/pom.xml index dd21214c8..4fdf1a66e 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 2.13.4 2.3.1 2.9.0 - 1.7.21 + 1.8.0 1.4.1 6.2.1.RELEASE 8.12.54 diff --git a/service/config/sample.yml b/service/config/sample.yml index 51c0d9dac..c68f168a1 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -352,7 +352,9 @@ subscription: # configuration for Stripe subscriptions # list of ISO 4217 currency codes and amounts for the given badge level xts: amount: '10' - id: price_example # stripe ID + processorIds: + STRIPE: price_example # stripe Price ID + BRAINTREE: plan_example # braintree Plan ID oneTimeDonations: boost: diff --git a/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql b/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql new file mode 100644 index 000000000..7d1887334 --- /dev/null +++ b/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql @@ -0,0 +1,6 @@ +mutation CreatePayPalBillingAgreement($input: CreatePayPalBillingAgreementInput!) { + createPayPalBillingAgreement(input: $input) { + approvalUrl, + billingAgreementToken + } +} diff --git a/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql b/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql new file mode 100644 index 000000000..ca07fa375 --- /dev/null +++ b/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql @@ -0,0 +1,7 @@ +mutation TokenizePayPalBillingAgreement($input: TokenizePayPalBillingAgreementInput!) { + tokenizePayPalBillingAgreement(input: $input) { + paymentMethod { + id + } + } +} diff --git a/service/src/main/graphql/braintree/VaultPaymentMethod.graphql b/service/src/main/graphql/braintree/VaultPaymentMethod.graphql new file mode 100644 index 000000000..bcce3f7ea --- /dev/null +++ b/service/src/main/graphql/braintree/VaultPaymentMethod.graphql @@ -0,0 +1,7 @@ +mutation VaultPaymentMethod($input: VaultPaymentMethodInput!) { + vaultPaymentMethod(input: $input) { + paymentMethod { + id + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java index 3e2aaef1b..21d3d0a2c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java @@ -5,31 +5,16 @@ package org.whispersystems.textsecuregcm.configuration; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; import java.math.BigDecimal; +import java.util.Map; +import javax.validation.Valid; import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -public class SubscriptionPriceConfiguration { +public record SubscriptionPriceConfiguration(@Valid @NotEmpty Map processorIds, + @NotNull @DecimalMin("0.01") BigDecimal amount) { - private final String id; - private final BigDecimal amount; - - @JsonCreator - public SubscriptionPriceConfiguration( - @JsonProperty("id") @NotEmpty String id, - @JsonProperty("amount") @NotNull @DecimalMin("0.01") BigDecimal amount) { - this.id = id; - this.amount = amount; - } - - public String getId() { - return id; - } - - public BigDecimal getAmount() { - return amount; - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index fbb0479c0..208ab1150 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -14,11 +14,6 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.annotations.VisibleForTesting; import com.stripe.exception.StripeException; -import com.stripe.model.Charge; -import com.stripe.model.Charge.Outcome; -import com.stripe.model.Invoice; -import com.stripe.model.InvoiceLineItem; -import com.stripe.model.Subscription; import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tags; @@ -31,7 +26,6 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Base64; -import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -47,6 +41,7 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.validation.Valid; import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import javax.ws.rs.BadRequestException; @@ -72,7 +67,6 @@ import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; -import org.apache.commons.lang3.StringUtils; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; @@ -87,7 +81,6 @@ import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfigurati import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.PurchasableBadge; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; @@ -98,6 +91,7 @@ import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.util.ExactlySize; @@ -168,7 +162,7 @@ public class SubscriptionController { .filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency)) .collect(Collectors.toMap( levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()), - levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).getAmount())); + levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount())); final List supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) .filter(paymentMethod -> subscriptionProcessorManagers.stream() @@ -233,19 +227,7 @@ public class SubscriptionController { throw new NotFoundException(); } return getResult.record.getProcessorCustomer() - .map(processorCustomer -> stripeManager.getCustomer(processorCustomer.customerId()) - .thenCompose(customer -> { - if (customer == null) { - throw new InternalServerErrorException( - "no customer record found for id " + processorCustomer.customerId()); - } - return stripeManager.listNonCanceledSubscriptions(customer); - }).thenCompose(subscriptions -> { - @SuppressWarnings("unchecked") - CompletableFuture[] futures = (CompletableFuture[]) subscriptions.stream() - .map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); - })) + .map(processorCustomer -> getManagerForProcessor(processorCustomer.processor()).cancelAllActiveSubscriptions(processorCustomer.customerId())) // a missing customer ID is OK; it means the subscriber never started to add a payment method .orElseGet(() -> CompletableFuture.completedFuture(null)); }) @@ -307,7 +289,14 @@ public class SubscriptionController { .thenCompose(record -> { final CompletableFuture updatedRecordFuture = record.getProcessorCustomer() - .map(ignored -> CompletableFuture.completedFuture(record)) + .map(ProcessorCustomer::processor) + .map(processor -> { + if (processor != subscriptionProcessorManager.getProcessor()) { + throw new ClientErrorException("existing processor does not match", Status.CONFLICT); + } + + return CompletableFuture.completedFuture(record); + }) .orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser) .thenApply(ProcessorCustomer::customerId) .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, @@ -317,6 +306,7 @@ public class SubscriptionController { return updatedRecordFuture.thenCompose( updatedRecord -> { final String customerId = updatedRecord.getProcessorCustomer() + .filter(pc -> pc.processor().equals(subscriptionProcessorManager.getProcessor())) .orElseThrow(() -> new InternalServerErrorException("record should not be missing customer")) .customerId(); return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId); @@ -327,6 +317,64 @@ public class SubscriptionController { .build()); } + public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) { + + } + + public record CreatePayPalBillingAgreementResponse(@NotBlank String approvalUrl, @NotBlank String token) { + + } + + @Timed + @POST + @Path("/{subscriberId}/create_payment_method/paypal") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture createPayPalPaymentMethod( + @Auth Optional authenticatedAccount, + @PathParam("subscriberId") String subscriberId, + @NotNull @Valid CreatePayPalBillingAgreementRequest request, + @Context ContainerRequestContext containerRequestContext) { + + RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); + + return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) + .thenApply(this::requireRecordFromGetResult) + .thenCompose(record -> { + + final CompletableFuture updatedRecordFuture = + record.getProcessorCustomer() + .map(ProcessorCustomer::processor) + .map(processor -> { + if (processor != braintreeManager.getProcessor()) { + throw new ClientErrorException("existing processor does not match", Status.CONFLICT); + } + return CompletableFuture.completedFuture(record); + }) + .orElseGet(() -> braintreeManager.createCustomer(requestData.subscriberUser) + .thenApply(ProcessorCustomer::customerId) + .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, + new ProcessorCustomer(customerId, braintreeManager.getProcessor()), + Instant.now()))); + + return updatedRecordFuture.thenCompose( + updatedRecord -> { + final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream() + .filter(l -> !"*".equals(l.getLanguage())) + .findFirst() + .orElse(Locale.US); + + return braintreeManager.createPayPalBillingAgreement(request.returnUrl, request.cancelUrl, + locale.toLanguageTag()); + }); + }) + .thenApply( + billingAgreementApprovalDetails -> Response.ok( + new CreatePayPalBillingAgreementResponse(billingAgreementApprovalDetails.approvalUrl(), + billingAgreementApprovalDetails.billingAgreementToken())) + .build()); + } + private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) { return switch (paymentMethod) { case CARD -> stripeManager; @@ -346,16 +394,38 @@ public class SubscriptionController { @Path("/{subscriberId}/default_payment_method/{paymentMethodId}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) + @Deprecated // use /{subscriberId}/default_payment_method/{processor}/{paymentMethodId} public CompletableFuture setDefaultPaymentMethod( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId, @PathParam("paymentMethodId") @NotEmpty String paymentMethodId) { RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); + return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) + .thenApply(this::requireRecordFromGetResult) + .thenCompose(record -> stripeManager.setDefaultPaymentMethodForCustomer( + record.getProcessorCustomer().orElseThrow().customerId(), paymentMethodId, record.subscriptionId)) + .thenApply(customer -> Response.ok().build()); + } + + @Timed + @POST + @Path("/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture setDefaultPaymentMethodWithProcessor( + @Auth Optional authenticatedAccount, + @PathParam("subscriberId") String subscriberId, + @PathParam("processor") SubscriptionProcessor processor, + @PathParam("paymentMethodToken") @NotEmpty String paymentMethodToken) { + RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); + + final SubscriptionProcessorManager manager = getManagerForProcessor(processor); + return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) .thenApply(this::requireRecordFromGetResult) .thenCompose(record -> record.getProcessorCustomer() - .map(processorCustomer -> stripeManager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), - paymentMethodId)) + .map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), + paymentMethodToken, record.subscriptionId)) .orElseThrow(() -> // a missing customer ID indicates the client made requests out of order, // and needs to call create_payment_method to create a customer for the given payment method @@ -435,76 +505,67 @@ public class SubscriptionController { return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) .thenApply(this::requireRecordFromGetResult) .thenCompose(record -> { - SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level); - if (levelConfiguration == null) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null)))) - .build()); - } - SubscriptionPriceConfiguration priceConfiguration = levelConfiguration.getPrices() - .get(currency.toLowerCase(Locale.ROOT)); - if (priceConfiguration == null) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null)))) - .build()); - } - if (record.subscriptionId == null) { - long lastSubscriptionCreatedAt = - record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0; + final ProcessorCustomer processorCustomer = record.getProcessorCustomer() + .orElseThrow(() -> + // a missing customer ID indicates the client made requests out of order, + // and needs to call create_payment_method to create a customer for the given payment method + new ClientErrorException(Status.CONFLICT)); - return record.getProcessorCustomer() - .map(processorCustomer -> - // we don't have a subscription yet so create it and then record the subscription id - // - // this relies on stripe's idempotency key to avoid creating more than one subscription if the client - // retries this request - stripeManager.createSubscription(processorCustomer.customerId(), priceConfiguration.getId(), level, - lastSubscriptionCreatedAt) - .exceptionally(e -> { - if (e.getCause() instanceof StripeException stripeException - && stripeException.getCode().equals("subscription_payment_intent_requires_action")) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null - ) - ))).build()); - } - if (e instanceof RuntimeException re) { + final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, processorCustomer.processor()); + + final SubscriptionProcessorManager manager = getManagerForProcessor(processorCustomer.processor()); + + return Optional.ofNullable(record.subscriptionId) + .map(subId -> { + // we already have a subscription in our records so let's check the level and change it if needed + return manager.getSubscription(subId).thenCompose( + subscription -> manager.getLevelForSubscription(subscription).thenCompose(existingLevel -> { + if (level == existingLevel) { + return CompletableFuture.completedFuture(subscription); + } + return manager.updateSubscription( + subscription, subscriptionTemplateId, level, idempotencyKey) + .thenCompose(updatedSubscription -> + subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, + requestData.now, + level, updatedSubscription.id()) + .thenApply(unused -> updatedSubscription)); + })); + }).orElseGet(() -> { + long lastSubscriptionCreatedAt = + record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0; + + // we don't have a subscription yet so create it and then record the subscription id + // + // this relies on stripe's idempotency key to avoid creating more than one subscription if the client + // retries this request + return manager.createSubscription(processorCustomer.customerId(), + subscriptionTemplateId, + level, + lastSubscriptionCreatedAt) + .exceptionally(e -> { + if (e.getCause() instanceof StripeException stripeException + && stripeException.getCode().equals("subscription_payment_intent_requires_action")) { + throw new BadRequestException(Response.status(Status.BAD_REQUEST) + .entity(new SetSubscriptionLevelErrorResponse(List.of( + new SetSubscriptionLevelErrorResponse.Error( + SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null + ) + ))).build()); + } + if (e instanceof RuntimeException re) { throw re; - } + } - throw new CompletionException(e); - }) + throw new CompletionException(e); + }) .thenCompose(subscription -> subscriptionManager.subscriptionCreated( - requestData.subscriberUser, subscription.getId(), requestData.now, level) - .thenApply(unused -> subscription))) - .orElseThrow(() -> - // a missing customer ID indicates the client made requests out of order, - // and needs to call create_payment_method to create a customer for the given payment method - new ClientErrorException(Status.CONFLICT)); - } else { - // we already have a subscription in our records so let's check the level and change it if needed - return stripeManager.getSubscription(record.subscriptionId).thenCompose( - subscription -> stripeManager.getLevelForSubscription(subscription).thenCompose(existingLevel -> { - if (level == existingLevel) { - return CompletableFuture.completedFuture(subscription); - } - return stripeManager.updateSubscription( - subscription, priceConfiguration.getId(), level, idempotencyKey) - .thenCompose(updatedSubscription -> - subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, requestData.now, - level) - .thenApply(unused -> updatedSubscription)); - })); - } + requestData.subscriberUser, subscription.id(), requestData.now, level) + .thenApply(unused -> subscription)); + }); }) - .thenApply(subscription -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build()); + .thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build()); } public static class GetLevelsResponse { @@ -613,7 +674,7 @@ public class SubscriptionController { badgeTranslator.translate(acceptableLanguages, entry.getValue().getBadge()), entry.getValue().getPrices().entrySet().stream().collect( Collectors.toMap(levelEntry -> levelEntry.getKey().toUpperCase(Locale.ROOT), - levelEntry -> levelEntry.getValue().getAmount())))))); + levelEntry -> levelEntry.getValue().amount())))))); return Response.ok(getLevelsResponse).build(); }); } @@ -730,6 +791,9 @@ public class SubscriptionController { } } + /** + * Creates a Stripe PaymentIntent with the requested amount and currency + */ @Timed @POST @Path("/boost/create") @@ -745,7 +809,7 @@ public class SubscriptionController { BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies() .get(request.currency.toLowerCase(Locale.ROOT)).gift(); if (amountConfigured == null || - stripeManager.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) + SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) .compareTo(amount) != 0) { throw new WebApplicationException( Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); @@ -773,7 +837,8 @@ public class SubscriptionController { BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() .get(request.currency.toLowerCase(Locale.ROOT)).minimum(); - BigDecimal minCurrencyAmountMinorUnits = stripeManager.convertConfiguredAmountToStripeAmount(request.currency, + BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( + request.currency, minCurrencyAmountMajorUnits); if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) { throw new BadRequestException(Response.status(Status.BAD_REQUEST) @@ -954,6 +1019,7 @@ public class SubscriptionController { private final String currency; private final BigDecimal amount; private final String status; + private final SubscriptionProcessor processor; @JsonCreator public Subscription( @@ -964,7 +1030,8 @@ public class SubscriptionController { @JsonProperty("cancelAtPeriodEnd") boolean cancelAtPeriodEnd, @JsonProperty("currency") String currency, @JsonProperty("amount") BigDecimal amount, - @JsonProperty("status") String status) { + @JsonProperty("status") String status, + @JsonProperty("processor") SubscriptionProcessor processor) { this.level = level; this.billingCycleAnchor = billingCycleAnchor; this.endOfCurrentPeriod = endOfCurrentPeriod; @@ -973,6 +1040,7 @@ public class SubscriptionController { this.currency = currency; this.amount = amount; this.status = status; + this.processor = processor; } public long getLevel() { @@ -1006,6 +1074,10 @@ public class SubscriptionController { public String getStatus() { return status; } + + public SubscriptionProcessor getProcessor() { + return processor; + } } public static class ChargeFailure { @@ -1082,38 +1154,38 @@ public class SubscriptionController { return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) .thenApply(this::requireRecordFromGetResult) .thenCompose(record -> { - if (record.subscriptionId == null) { - return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build()); - } - return stripeManager.getSubscription(record.subscriptionId).thenCompose(subscription -> - stripeManager.getPriceForSubscription(subscription).thenCompose(price -> - stripeManager.getLevelForPrice(price).thenApply(level -> { - GetSubscriptionInformationResponse.ChargeFailure chargeFailure = null; - if (subscription.getLatestInvoiceObject() != null && subscription.getLatestInvoiceObject().getChargeObject() != null && - (subscription.getLatestInvoiceObject().getChargeObject().getFailureCode() != null || subscription.getLatestInvoiceObject().getChargeObject().getFailureMessage() != null)) { - Charge charge = subscription.getLatestInvoiceObject().getChargeObject(); - Outcome outcome = charge.getOutcome(); - chargeFailure = new GetSubscriptionInformationResponse.ChargeFailure( - charge.getFailureCode(), - charge.getFailureMessage(), - outcome != null ? outcome.getNetworkStatus() : null, - outcome != null ? outcome.getReason() : null, - outcome != null ? outcome.getType() : null); - } - return Response.ok( - new GetSubscriptionInformationResponse( - new GetSubscriptionInformationResponse.Subscription( - level, - Instant.ofEpochSecond(subscription.getBillingCycleAnchor()), - Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()), - Objects.equals(subscription.getStatus(), "active"), - subscription.getCancelAtPeriodEnd(), - price.getCurrency().toUpperCase(Locale.ROOT), - price.getUnitAmountDecimal(), - subscription.getStatus()), - chargeFailure - )).build(); - }))); + if (record.subscriptionId == null) { + return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build()); + } + + final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); + + return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> + manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> { + final GetSubscriptionInformationResponse.ChargeFailure chargeFailure = Optional.ofNullable(subscriptionInformation.chargeFailure()) + .map(chargeFailure1 -> new GetSubscriptionInformationResponse.ChargeFailure( + subscriptionInformation.chargeFailure().code(), + subscriptionInformation.chargeFailure().message(), + subscriptionInformation.chargeFailure().outcomeNetworkStatus(), + subscriptionInformation.chargeFailure().outcomeReason(), + subscriptionInformation.chargeFailure().outcomeType() + )) + .orElse(null); + return Response.ok( + new GetSubscriptionInformationResponse( + new GetSubscriptionInformationResponse.Subscription( + subscriptionInformation.level(), + subscriptionInformation.billingCycleAnchor(), + subscriptionInformation.endOfCurrentPeriod(), + subscriptionInformation.active(), + subscriptionInformation.cancelAtPeriodEnd(), + subscriptionInformation.price().currency(), + subscriptionInformation.price().amount(), + subscriptionInformation.status().getApiValue(), + manager.getProcessor()), + chargeFailure + )).build(); + })); }); } @@ -1162,98 +1234,60 @@ public class SubscriptionController { return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) .thenApply(this::requireRecordFromGetResult) .thenCompose(record -> { - if (record.subscriptionId == null) { - return CompletableFuture.completedFuture(Response.status(Status.NOT_FOUND).build()); - } - ReceiptCredentialRequest receiptCredentialRequest; - try { - receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest()); - } catch (InvalidInputException e) { - throw new BadRequestException("invalid receipt credential request", e); - } - return stripeManager.getLatestInvoiceForSubscription(record.subscriptionId) - .thenCompose(invoice -> convertInvoiceToReceipt(invoice, record.subscriptionId)) - .thenCompose(receipt -> issuedReceiptsManager.recordIssuance( - receipt.getInvoiceLineItemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest, - requestData.now) - .thenApply(unused -> receipt)) - .thenApply(receipt -> { - ReceiptCredentialResponse receiptCredentialResponse; - try { - receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( - receiptCredentialRequest, receipt.getExpiration().getEpochSecond(), receipt.getLevel()); - } catch (VerificationFailedException e) { - throw new BadRequestException("receipt credential request failed verification", e); - } - return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build(); - }); + if (record.subscriptionId == null) { + return CompletableFuture.completedFuture(Response.status(Status.NOT_FOUND).build()); + } + ReceiptCredentialRequest receiptCredentialRequest; + try { + receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest()); + } catch (InvalidInputException e) { + throw new BadRequestException("invalid receipt credential request", e); + } + + final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); + return manager.getReceiptItem(record.subscriptionId) + .thenCompose(receipt -> issuedReceiptsManager.recordIssuance( + receipt.itemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest, + requestData.now) + .thenApply(unused -> receipt)) + .thenApply(receipt -> { + ReceiptCredentialResponse receiptCredentialResponse; + try { + receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( + receiptCredentialRequest, receiptExpirationWithGracePeriod(receipt.expiration()).getEpochSecond(), receipt.level()); + } catch (VerificationFailedException e) { + throw new BadRequestException("receipt credential request failed verification", e); + } + return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build(); + }); }); } - public static class Receipt { - - private final Instant expiration; - private final long level; - private final String invoiceLineItemId; - - public Receipt(Instant expiration, long level, String invoiceLineItemId) { - this.expiration = expiration; - this.level = level; - this.invoiceLineItemId = invoiceLineItemId; + private Instant receiptExpirationWithGracePeriod(Instant itemExpiration) { + return itemExpiration.plus(subscriptionConfiguration.getBadgeGracePeriod()) + .truncatedTo(ChronoUnit.DAYS) + .plus(1, ChronoUnit.DAYS); } - public Instant getExpiration() { - return expiration; - } - - public long getLevel() { - return level; - } - - public String getInvoiceLineItemId() { - return invoiceLineItemId; - } - } - - private CompletableFuture convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) { - if (latestSubscriptionInvoice == null) { - throw new WebApplicationException(Status.NO_CONTENT); - } - if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) { - throw new WebApplicationException(Status.NO_CONTENT); - } - if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) { - throw new WebApplicationException(Status.PAYMENT_REQUIRED); - } - - return stripeManager.getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> { - Collection subscriptionLineItems = invoiceLineItems.stream() - .filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType())) - .collect(Collectors.toList()); - if (subscriptionLineItems.isEmpty()) { - throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId=" - + subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId()); - } - if (subscriptionLineItems.size() > 1) { - throw new IllegalStateException( - "latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId - + "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size()); + private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) { + SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level); + if (levelConfiguration == null) { + throw new BadRequestException(Response.status(Status.BAD_REQUEST) + .entity(new SetSubscriptionLevelErrorResponse(List.of( + new SetSubscriptionLevelErrorResponse.Error( + SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null)))) + .build()); } - InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get(); - return getReceiptForSubscriptionInvoiceLineItem(subscriptionLineItem); - }); - } - - private CompletableFuture getReceiptForSubscriptionInvoiceLineItem(InvoiceLineItem subscriptionLineItem) { - return stripeManager.getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new Receipt( - Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getEnd()) - .plus(subscriptionConfiguration.getBadgeGracePeriod()) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS), - stripeManager.getLevelForProduct(product), - subscriptionLineItem.getId())); - } + return Optional.ofNullable(levelConfiguration.getPrices() + .get(currency.toLowerCase(Locale.ROOT))) + .map(priceConfiguration -> priceConfiguration.processorIds().get(processor)) + .orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST) + .entity(new SetSubscriptionLevelErrorResponse(List.of( + new SetSubscriptionLevelErrorResponse.Error( + SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null)))) + .build())); + } private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) { if (getResult == GetResult.PASSWORD_MISMATCH) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java index 7a0d5c634..8837e1eb8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java @@ -65,6 +65,7 @@ public class SubscriptionManager { @VisibleForTesting @Nullable ProcessorCustomer processorCustomer; + @Nullable public String subscriptionId; public Instant subscriptionCreatedAt; public Long subscriptionLevel; @@ -187,7 +188,8 @@ public class SubscriptionManager { if (count == 0) { return null; } else if (count > 1) { - logger.error("expected invariant of 1-1 subscriber-customer violated for customer {}", processorCustomer); + logger.error("expected invariant of 1-1 subscriber-customer violated for customer {} ({})", + processorCustomer.customerId(), processorCustomer.processor()); throw new IllegalStateException( "expected invariant of 1-1 subscriber-customer violated for customer " + processorCustomer); } else { @@ -392,7 +394,7 @@ public class SubscriptionManager { } public CompletableFuture subscriptionLevelChanged( - byte[] user, Instant subscriptionLevelChangedAt, long level) { + byte[] user, Instant subscriptionLevelChangedAt, long level, String subscriptionId) { checkUserLength(user); UpdateItemRequest request = UpdateItemRequest.builder() @@ -401,14 +403,17 @@ public class SubscriptionManager { .returnValues(ReturnValue.NONE) .updateExpression("SET " + "#accessed_at = :accessed_at, " + + "#subscription_id = :subscription_id, " + "#subscription_level = :subscription_level, " + "#subscription_level_changed_at = :subscription_level_changed_at") .expressionAttributeNames(Map.of( "#accessed_at", KEY_ACCESSED_AT, + "#subscription_id", KEY_SUBSCRIPTION_ID, "#subscription_level", KEY_SUBSCRIPTION_LEVEL, "#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT)) .expressionAttributeValues(Map.of( ":accessed_at", n(subscriptionLevelChangedAt.getEpochSecond()), + ":subscription_id", s(subscriptionId), ":subscription_level", n(level), ":subscription_level_changed_at", n(subscriptionLevelChangedAt.getEpochSecond()))) .build(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java index 2e83ea133..2c59fea0c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java @@ -11,19 +11,29 @@ import com.apollographql.apollo3.api.Operations; import com.apollographql.apollo3.api.Optional; import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter; import com.braintree.graphql.client.type.ChargePaymentMethodInput; +import com.braintree.graphql.client.type.CreatePayPalBillingAgreementInput; import com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput; import com.braintree.graphql.client.type.CustomFieldInput; import com.braintree.graphql.client.type.MonetaryAmountInput; +import com.braintree.graphql.client.type.PayPalBillingAgreementChargePattern; +import com.braintree.graphql.client.type.PayPalBillingAgreementExperienceProfileInput; +import com.braintree.graphql.client.type.PayPalBillingAgreementInput; import com.braintree.graphql.client.type.PayPalExperienceProfileInput; import com.braintree.graphql.client.type.PayPalIntent; import com.braintree.graphql.client.type.PayPalLandingPageType; import com.braintree.graphql.client.type.PayPalOneTimePaymentInput; +import com.braintree.graphql.client.type.PayPalProductAttributesInput; import com.braintree.graphql.client.type.PayPalUserAction; +import com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput; import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput; import com.braintree.graphql.client.type.TransactionInput; +import com.braintree.graphql.client.type.VaultPaymentMethodInput; import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation; +import com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation; import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation; +import com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation; import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation; +import com.braintree.graphql.clientoperation.VaultPaymentMethodMutation; import java.math.BigDecimal; import java.net.URI; import java.net.URISyntaxException; @@ -185,6 +195,94 @@ class BraintreeGraphqlClient { ); } + public CompletableFuture createPayPalBillingAgreement( + final String returnUrl, final String cancelUrl, final String locale) { + + final CreatePayPalBillingAgreementInput input = buildCreatePayPalBillingAgreementInput(returnUrl, cancelUrl, + locale); + final CreatePayPalBillingAgreementMutation mutation = new CreatePayPalBillingAgreementMutation(input); + final HttpRequest request = buildRequest(mutation); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(httpResponse -> { + // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” + // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ + final CreatePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); + return data.createPayPalBillingAgreement; + }); + } + + private static CreatePayPalBillingAgreementInput buildCreatePayPalBillingAgreementInput(String returnUrl, + String cancelUrl, String locale) { + + return new CreatePayPalBillingAgreementInput( + Optional.absent(), + Optional.absent(), + returnUrl, + cancelUrl, + Optional.absent(), + Optional.absent(), + Optional.present(false), // offerPayPalCredit + Optional.absent(), + Optional.present( + new PayPalBillingAgreementExperienceProfileInput(Optional.present("Signal"), + Optional.present(false), // collectShippingAddress + Optional.present(PayPalLandingPageType.LOGIN), + Optional.present(locale), + Optional.absent())), + Optional.absent(), + Optional.present(new PayPalProductAttributesInput( + Optional.present(PayPalBillingAgreementChargePattern.RECURRING_PREPAID) + )) + ); + } + + public CompletableFuture tokenizePayPalBillingAgreement( + final String billingAgreementToken) { + + final TokenizePayPalBillingAgreementInput input = new TokenizePayPalBillingAgreementInput( + Optional.absent(), + new PayPalBillingAgreementInput(billingAgreementToken)); + final TokenizePayPalBillingAgreementMutation mutation = new TokenizePayPalBillingAgreementMutation(input); + final HttpRequest request = buildRequest(mutation); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(httpResponse -> { + // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” + // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ + final TokenizePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); + return data.tokenizePayPalBillingAgreement; + }); + } + + public CompletableFuture vaultPaymentMethod(final String customerId, + final String paymentMethodId) { + + final VaultPaymentMethodInput input = buildVaultPaymentMethodInput(customerId, paymentMethodId); + final VaultPaymentMethodMutation mutation = new VaultPaymentMethodMutation(input); + final HttpRequest request = buildRequest(mutation); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(httpResponse -> { + // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” + // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ + final VaultPaymentMethodMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); + return data.vaultPaymentMethod; + }); + } + + private static VaultPaymentMethodInput buildVaultPaymentMethodInput(String customerId, String paymentMethodId) { + return new VaultPaymentMethodInput( + Optional.absent(), + paymentMethodId, + Optional.absent(), + Optional.absent(), + Optional.present(customerId), + Optional.absent(), + Optional.absent() + ); + } + /** * Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise * throws a {@link ServiceUnavailableException}. diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index 653dd446d..aa9126282 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -6,19 +6,36 @@ package org.whispersystems.textsecuregcm.subscriptions; import com.braintreegateway.BraintreeGateway; +import com.braintreegateway.ClientTokenRequest; +import com.braintreegateway.Customer; +import com.braintreegateway.CustomerRequest; +import com.braintreegateway.Plan; import com.braintreegateway.ResourceCollection; +import com.braintreegateway.Result; +import com.braintreegateway.Subscription; +import com.braintreegateway.SubscriptionRequest; import com.braintreegateway.Transaction; import com.braintreegateway.TransactionSearchRequest; +import com.braintreegateway.exceptions.BraintreeException; import com.braintreegateway.exceptions.NotFoundException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; -import javax.ws.rs.BadRequestException; +import javax.annotation.Nullable; +import javax.ws.rs.ClientErrorException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; +import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; @@ -97,16 +114,6 @@ public class BraintreeManager implements SubscriptionProcessorManager { }, executor); } - @Override - public CompletableFuture createCustomer(final byte[] subscriberUser) { - return CompletableFuture.failedFuture(new BadRequestException("Unsupported")); - } - - @Override - public CompletableFuture createPaymentMethodSetupToken(final String customerId) { - return CompletableFuture.failedFuture(new BadRequestException("Unsupported")); - } - public CompletableFuture createOneTimePayment(String currency, long amount, String locale, String returnUrl, String cancelUrl) { return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount), @@ -187,6 +194,19 @@ public class BraintreeManager implements SubscriptionProcessorManager { } } + private static SubscriptionStatus getSubscriptionStatus(final Subscription.Status status) { + return switch (status) { + case ACTIVE -> SubscriptionStatus.ACTIVE; + case CANCELED, EXPIRED -> SubscriptionStatus.CANCELED; + case PAST_DUE -> SubscriptionStatus.PAST_DUE; + case PENDING -> SubscriptionStatus.INCOMPLETE; + case UNRECOGNIZED -> { + logger.error("Subscription has unrecognized status; library may need to be updated: {}", status); + yield SubscriptionStatus.UNKNOWN; + } + }; + } + private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) { return switch (currency.toLowerCase(Locale.ROOT)) { // JPY is the only supported zero-decimal currency @@ -203,4 +223,311 @@ public class BraintreeManager implements SubscriptionProcessorManager { } + private void assertResultSuccess(Result result) throws CompletionException { + if (!result.isSuccess()) { + throw new CompletionException(new BraintreeException(result.getMessage())); + } + } + + @Override + public CompletableFuture createCustomer(final byte[] subscriberUser) { + return CompletableFuture.supplyAsync(() -> { + final CustomerRequest request = new CustomerRequest() + .customField("subscriber_user", Hex.encodeHexString(subscriberUser)); + try { + return braintreeGateway.customer().create(request); + } catch (BraintreeException e) { + throw new CompletionException(e); + } + }, executor) + .thenApply(result -> { + assertResultSuccess(result); + + return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE); + }); + + } + + @Override + public CompletableFuture createPaymentMethodSetupToken(final String customerId) { + return CompletableFuture.supplyAsync(() -> { + ClientTokenRequest request = new ClientTokenRequest() + .customerId(customerId); + + return braintreeGateway.clientToken().generate(request); + }, executor); + } + + @Override + public CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken, + @Nullable String currentSubscriptionId) { + final Optional maybeSubscriptionId = Optional.ofNullable(currentSubscriptionId); + return braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken) + .thenCompose(tokenizePayPalBillingAgreement -> + braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id)) + .thenApplyAsync(vaultPaymentMethod -> braintreeGateway.customer() + .update(customerId, new CustomerRequest() + .defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id)), + executor) + .thenAcceptAsync(result -> { + maybeSubscriptionId.ifPresent( + subscriptionId -> braintreeGateway.subscription() + .update(subscriptionId, new SubscriptionRequest() + .paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken()))); + }, executor); + } + + @Override + public CompletableFuture getSubscription(String subscriptionId) { + return CompletableFuture.supplyAsync(() -> braintreeGateway.subscription().find(subscriptionId), executor); + } + + @Override + public CompletableFuture createSubscription(String customerId, String planId, long level, + long lastSubscriptionCreatedAt) { + + return getDefaultPaymentMethod(customerId) + .thenCompose(paymentMethod -> { + if (paymentMethod == null) { + throw new ClientErrorException(Response.Status.CONFLICT); + } + + final Optional maybeExistingSubscription = paymentMethod.getSubscriptions().stream() + .filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE)) + .filter(Subscription::neverExpires) + .findAny(); + + final CompletableFuture planFuture = maybeExistingSubscription.map(sub -> + findPlan(sub.getPlanId()).thenApply(plan -> { + if (getLevelForPlan(plan) != level) { + // if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption) + // with a different level. + // In this case, it’s safer and easier to recover by returning this subscription, rather than + // returning an error + logger.warn("existing subscription had unexpected level"); + } + return plan; + })).orElseGet(() -> findPlan(planId)); + + return maybeExistingSubscription + .map(subscription -> { + return findPlan(subscription.getPlanId()) + .thenApply(plan -> { + if (getLevelForPlan(plan) != level) { + // if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption) + // with a different level. + // In this case, it’s safer and easier to recover by returning this subscription, rather than + // returning an error + logger.warn("existing subscription had unexpected level"); + } + return subscription; + }); + }) + .orElseGet(() -> findPlan(planId).thenApplyAsync(plan -> { + final Result result = braintreeGateway.subscription().create(new SubscriptionRequest() + .planId(planId) + .paymentMethodToken(paymentMethod.getToken()) + .merchantAccountId( + currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT))) + .options() + .startImmediately(true) + .done() + ); + + assertResultSuccess(result); + + return result.getTarget(); + })); + }).thenApply(subscription -> new SubscriptionId(subscription.getId())); + } + + private CompletableFuture getDefaultPaymentMethod(String customerId) { + return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId).getDefaultPaymentMethod(), + executor); + } + + + @Override + public CompletableFuture updateSubscription(Object subscriptionObj, String planId, long level, + String idempotencyKey) { + + if (!(subscriptionObj instanceof final Subscription subscription)) { + throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); + } + + // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and + // and not prorated. Braintree subscriptions cannot change their next billing date, + // so we must end the existing one and create a new one + return cancelSubscriptionAtEndOfCurrentPeriod(subscription) + .thenCompose(ignored -> { + + final Transaction transaction = getLatestTransactionForSubscription(subscription).orElseThrow( + () -> new ClientErrorException( + Response.Status.CONFLICT)); + + final Customer customer = transaction.getCustomer(); + + return createSubscription(customer.getId(), planId, level, + subscription.getCreatedAt().toInstant().getEpochSecond()); + }); + } + + @Override + public CompletableFuture getLevelForSubscription(Object subscriptionObj) { + final Subscription subscription = getSubscription(subscriptionObj); + + return findPlan(subscription.getPlanId()) + .thenApply(this::getLevelForPlan); + } + + private CompletableFuture findPlan(String planId) { + return CompletableFuture.supplyAsync(() -> braintreeGateway.plan().find(planId), executor); + } + + private long getLevelForPlan(final Plan plan) { + final BraintreePlanMetadata metadata; + try { + metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class); + + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + return metadata.level(); + } + + @Override + public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { + final Subscription subscription = getSubscription(subscriptionObj); + + return CompletableFuture.supplyAsync(() -> { + + final Plan plan = braintreeGateway.plan().find(subscription.getPlanId()); + + final long level = getLevelForPlan(plan); + + final Instant anchor = subscription.getFirstBillingDate().toInstant(); + final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant(); + + final Optional maybeTransaction = getLatestTransactionForSubscription(subscription); + + final ChargeFailure chargeFailure = maybeTransaction.map(transaction -> { + + if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { + return null; + } + + final String code; + final String message; + if (transaction.getProcessorResponseCode() != null) { + code = transaction.getProcessorResponseCode(); + message = transaction.getProcessorResponseText(); + } else if (transaction.getGatewayRejectionReason() != null) { + code = "gateway"; + message = transaction.getGatewayRejectionReason().toString(); + } else { + code = "unknown"; + message = "unknown"; + } + + return new ChargeFailure( + code, + message, + null, + null, + null); + + }).orElse(null); + + + return new SubscriptionInformation( + new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT), + SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())), + level, + anchor, + endOfCurrentPeriod, + Subscription.Status.ACTIVE == subscription.getStatus(), + !subscription.neverExpires(), + getSubscriptionStatus(subscription.getStatus()), + chargeFailure + ); + }, executor); + } + + @Override + public CompletableFuture cancelAllActiveSubscriptions(String customerId) { + + return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId), executor).thenCompose(customer -> { + + final List> subscriptionCancelFutures = customer.getDefaultPaymentMethod().getSubscriptions().stream() + .map(this::cancelSubscriptionAtEndOfCurrentPeriod) + .toList(); + + return CompletableFuture.allOf(subscriptionCancelFutures.toArray(new CompletableFuture[0])); + }); + } + + private CompletableFuture cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) { + return CompletableFuture.supplyAsync(() -> { + braintreeGateway.subscription().update(subscription.getId(), + new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle())); + return null; + }, executor); + } + + + @Override + public CompletableFuture getReceiptItem(String subscriptionId) { + + return getLatestTransactionForSubscription(subscriptionId).thenApply(maybeTransaction -> maybeTransaction.map(transaction -> { + + if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { + throw new WebApplicationException(Response.Status.PAYMENT_REQUIRED); + } + + final Instant expiration = transaction.getSubscriptionDetails().getBillingPeriodEndDate().toInstant(); + final Plan plan = braintreeGateway.plan().find(transaction.getPlanId()); + + final BraintreePlanMetadata metadata; + try { + metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class); + + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + return new ReceiptItem(transaction.getId(), expiration, metadata.level()); + + }).orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT))); + } + + private static Subscription getSubscription(Object subscriptionObj) { + if (!(subscriptionObj instanceof final Subscription subscription)) { + throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName()); + } + return subscription; + } + + public CompletableFuture> getLatestTransactionForSubscription(String subscriptionId) { + return getSubscription(subscriptionId) + .thenApply(BraintreeManager::getSubscription) + .thenApply(this::getLatestTransactionForSubscription); + } + + private Optional getLatestTransactionForSubscription(Subscription subscription) { + return subscription.getTransactions().stream() + .max(Comparator.comparing(Transaction::getCreatedAt)); + } + + public CompletableFuture createPayPalBillingAgreement(final String returnUrl, + final String cancelUrl, final String locale) { + return braintreeGraphqlClient.createPayPalBillingAgreement(returnUrl, cancelUrl, locale) + .thenApply(response -> + new PayPalBillingAgreementApprovalDetails((String) response.approvalUrl, response.billingAgreementToken) + ); + } + + public record PayPalBillingAgreementApprovalDetails(String approvalUrl, String billingAgreementToken) { + + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java new file mode 100644 index 000000000..ab60cf699 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java @@ -0,0 +1,10 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +public record BraintreePlanMetadata(long level) { + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index 2f48a1bb1..31730ddae 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.subscriptions; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.stripe.exception.StripeException; +import com.stripe.model.Charge; import com.stripe.model.Customer; import com.stripe.model.Invoice; import com.stripe.model.InvoiceLineItem; @@ -33,7 +34,6 @@ import com.stripe.param.SubscriptionRetrieveParams; import com.stripe.param.SubscriptionUpdateParams; import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor; import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior; -import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -57,10 +57,12 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import org.apache.commons.codec.binary.Hex; +import org.apache.commons.lang3.StringUtils; import org.whispersystems.textsecuregcm.util.Conversions; public class StripeManager implements SubscriptionProcessorManager { @@ -144,7 +146,9 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } - public CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId) { + @Override + public CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId, + @Nullable String currentSubscriptionId) { return CompletableFuture.supplyAsync(() -> { Customer customer = new Customer(); customer.setId(customerId); @@ -154,7 +158,8 @@ public class StripeManager implements SubscriptionProcessorManager { .build()) .build(); try { - return customer.update(params, commonOptions()); + customer.update(params, commonOptions()); + return null; } catch (StripeException e) { throw new CompletionException(e); } @@ -234,65 +239,78 @@ public class StripeManager implements SubscriptionProcessorManager { }; } - public CompletableFuture createSubscription(String customerId, String priceId, long level, + private static SubscriptionStatus getSubscriptionStatus(final String status) { + return SubscriptionStatus.forApiValue(status); + } + + @Override + public CompletableFuture createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) { return CompletableFuture.supplyAsync(() -> { - SubscriptionCreateParams params = SubscriptionCreateParams.builder() - .setCustomer(customerId) - .setOffSession(true) - .setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) - .addItem(SubscriptionCreateParams.Item.builder() - .setPrice(priceId) - .build()) - .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) - .build(); - try { - // the idempotency key intentionally excludes priceId - // - // If the client tells the server several times in a row before the initial creation of a subscription to - // create a subscription, we want to ensure only one gets created. - return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( - customerId, lastSubscriptionCreatedAt))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); + SubscriptionCreateParams params = SubscriptionCreateParams.builder() + .setCustomer(customerId) + .setOffSession(true) + .setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) + .addItem(SubscriptionCreateParams.Item.builder() + .setPrice(priceId) + .build()) + .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) + .build(); + try { + // the idempotency key intentionally excludes priceId + // + // If the client tells the server several times in a row before the initial creation of a subscription to + // create a subscription, we want to ensure only one gets created. + return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( + customerId, lastSubscriptionCreatedAt))); + } catch (StripeException e) { + throw new CompletionException(e); + } + }, executor) + .thenApply(subscription -> new SubscriptionId(subscription.getId())); } - public CompletableFuture updateSubscription( - Subscription subscription, String priceId, long level, String idempotencyKey) { + @Override + public CompletableFuture updateSubscription( + Object subscriptionObj, String priceId, long level, String idempotencyKey) { + + if (!(subscriptionObj instanceof final Subscription subscription)) { + throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); + } + return CompletableFuture.supplyAsync(() -> { - List items = new ArrayList<>(); - for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) { - items.add(SubscriptionUpdateParams.Item.builder() - .setId(item.getId()) - .setDeleted(true) - .build()); - } - items.add(SubscriptionUpdateParams.Item.builder() - .setPrice(priceId) - .build()); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) + List items = new ArrayList<>(); + for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) { + items.add(SubscriptionUpdateParams.Item.builder() + .setId(item.getId()) + .setDeleted(true) + .build()); + } + items.add(SubscriptionUpdateParams.Item.builder() + .setPrice(priceId) + .build()); + SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() + .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) - // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and - // not prorated - .setProrationBehavior(ProrationBehavior.NONE) - .setBillingCycleAnchor(BillingCycleAnchor.NOW) - .setOffSession(true) - .setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) - .addAllItem(items) - .build(); - try { - return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate( - subscription.getCustomer(), idempotencyKey))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); + // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and + // not prorated + .setProrationBehavior(ProrationBehavior.NONE) + .setBillingCycleAnchor(BillingCycleAnchor.NOW) + .setOffSession(true) + .setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) + .addAllItem(items) + .build(); + try { + return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate( + subscription.getCustomer(), idempotencyKey))); + } catch (StripeException e) { + throw new CompletionException(e); + } + }, executor) + .thenApply(subscription1 -> new SubscriptionId(subscription1.getId())); } - public CompletableFuture getSubscription(String subscriptionId) { + public CompletableFuture getSubscription(String subscriptionId) { return CompletableFuture.supplyAsync(() -> { SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() .addExpand("latest_invoice") @@ -306,6 +324,21 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } + public CompletableFuture cancelAllActiveSubscriptions(String customerId) { + return getCustomer(customerId).thenCompose(customer -> { + if (customer == null) { + throw new InternalServerErrorException( + "no customer record found for id " + customerId); + } + return listNonCanceledSubscriptions(customer); + }).thenCompose(subscriptions -> { + @SuppressWarnings("unchecked") + CompletableFuture[] futures = (CompletableFuture[]) subscriptions.stream() + .map(this::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + }); + } + public CompletableFuture> listNonCanceledSubscriptions(Customer customer) { return CompletableFuture.supplyAsync(() -> { SubscriptionListParams params = SubscriptionListParams.builder() @@ -362,11 +395,16 @@ public class StripeManager implements SubscriptionProcessorManager { }); } - public CompletableFuture getProductForSubscription(Subscription subscription) { + private CompletableFuture getProductForSubscription(Subscription subscription) { return getPriceForSubscription(subscription).thenCompose(price -> getProductForPrice(price.getId())); } - public CompletableFuture getLevelForSubscription(Subscription subscription) { + @Override + public CompletableFuture getLevelForSubscription(Object subscriptionObj) { + if (!(subscriptionObj instanceof final Subscription subscription)) { + + throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName()); + } return getProductForSubscription(subscription).thenApply(this::getLevelForProduct); } @@ -404,7 +442,7 @@ public class StripeManager implements SubscriptionProcessorManager { .build(); try { ArrayList invoices = Lists.newArrayList(Invoice.list(params, commonOptions()) - .autoPagingIterable(null, commonOptions())); + .autoPagingIterable(null, commonOptions())); invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed()); return invoices; } catch (StripeException e) { @@ -413,6 +451,54 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } + @Override + public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { + + final Subscription subscription = getSubscription(subscriptionObj); + + return getPriceForSubscription(subscription).thenCompose(price -> + getLevelForPrice(price).thenApply(level -> { + ChargeFailure chargeFailure = null; + + if (subscription.getLatestInvoiceObject() != null && subscription.getLatestInvoiceObject().getChargeObject() != null && + (subscription.getLatestInvoiceObject().getChargeObject().getFailureCode() != null || subscription.getLatestInvoiceObject().getChargeObject().getFailureMessage() != null)) { + Charge charge = subscription.getLatestInvoiceObject().getChargeObject(); + Charge.Outcome outcome = charge.getOutcome(); + chargeFailure = new ChargeFailure( + charge.getFailureCode(), + charge.getFailureMessage(), + outcome != null ? outcome.getNetworkStatus() : null, + outcome != null ? outcome.getReason() : null, + outcome != null ? outcome.getType() : null); + } + + return new SubscriptionInformation( + new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()), + level, + Instant.ofEpochSecond(subscription.getBillingCycleAnchor()), + Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()), + Objects.equals(subscription.getStatus(), "active"), + subscription.getCancelAtPeriodEnd(), + getSubscriptionStatus(subscription.getStatus()), + chargeFailure + ); + })); + } + + private Subscription getSubscription(Object subscriptionObj) { + if (!(subscriptionObj instanceof final Subscription subscription)) { + throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); + } + + return subscription; + } + + @Override + public CompletableFuture getReceiptItem(String subscriptionId) { + return getLatestInvoiceForSubscription(subscriptionId) + .thenCompose(invoice -> convertInvoiceToReceipt(invoice, subscriptionId)); + } + public CompletableFuture getLatestInvoiceForSubscription(String subscriptionId) { return CompletableFuture.supplyAsync(() -> { SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() @@ -426,24 +512,48 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } + private CompletableFuture convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) { + if (latestSubscriptionInvoice == null) { + throw new WebApplicationException(Status.NO_CONTENT); + } + if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) { + throw new WebApplicationException(Status.NO_CONTENT); + } + if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) { + throw new WebApplicationException(Status.PAYMENT_REQUIRED); + } + + return getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> { + Collection subscriptionLineItems = invoiceLineItems.stream() + .filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType())) + .toList(); + if (subscriptionLineItems.isEmpty()) { + throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId=" + + subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId()); + } + if (subscriptionLineItems.size() > 1) { + throw new IllegalStateException( + "latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId + + "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size()); + } + + InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get(); + return getReceiptForSubscriptionInvoiceLineItem(subscriptionLineItem); + }); + } + + private CompletableFuture getReceiptForSubscriptionInvoiceLineItem(InvoiceLineItem subscriptionLineItem) { + return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem( + subscriptionLineItem.getId(), + Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getEnd()), + getLevelForProduct(product))); + } + public CompletableFuture> getInvoiceLineItemsForInvoice(Invoice invoice) { return CompletableFuture.supplyAsync( () -> Lists.newArrayList(invoice.getLines().autoPagingIterable(null, commonOptions())), executor); } - /** - * Takes an amount as configured; for instance USD 4.99 and turns it into an amount as Stripe expects to see it. - * Stripe appears to only support 0 and 2 decimal currencies, but also has some backwards compatibility issues with 0 - * decimal currencies so this is not to any ISO standard but rather directly from Stripe's API doc page. - */ - public BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) { - return switch (currency.toLowerCase(Locale.ROOT)) { - // Yuck, but this list was taken from https://stripe.com/docs/currencies?presentment-currency=US - case "bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf" -> configuredAmount; - default -> configuredAmount.scaleByPowerOfTen(2); - }; - } - /** * We use a client generated idempotency key for subscription updates due to not being able to distinguish between a * call to update to level 2, then back to level 1, then back to level 2. If this all happens within Stripe's diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java new file mode 100644 index 000000000..d3c943cac --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import java.math.BigDecimal; +import java.util.Locale; +import java.util.Set; + +/** + * Utility for scaling amounts among Stripe, Braintree, configuration, and API responses. + *

+ * In general, the API input and output follow’s Stripe’s specification to use amounts in a currency’s + * smallest unit. The exception is configuration APIs, which return values in the currency’s primary unit. Braintree + * uses the currency’s primary unit for its input and output. + *

Examples

+ * + * + * API + * + * + * + * + * + * + * + * + * + *
Currency, AmountStripeBraintree
USD 4.994994994.99
JPY 501501501501
+ */ +public class SubscriptionCurrencyUtil { + + // This list was taken from https://stripe.com/docs/currencies?presentment-currency=US + // Braintree + private static final Set stripeZeroDecimalCurrencies = Set.of("bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", + "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf"); + + + /** + * Takes an amount as configured and turns it into an amount as API clients (and Stripe) expect to see it. For + * instance, {@code USD 4.99} return {@code 499}, while {@code JPY 500} returns {@code 500}. + * + *

+ * Stripe appears to only support zero- and two-decimal currencies, but also has some backwards compatibility issues + * with 0 decimal currencies, so this is not to any ISO standard but rather directly from Stripe's API doc page. + */ + public static BigDecimal convertConfiguredAmountToApiAmount(String currency, BigDecimal configuredAmount) { + if (stripeZeroDecimalCurrencies.contains(currency.toLowerCase(Locale.ROOT))) { + return configuredAmount; + } + + return configuredAmount.scaleByPowerOfTen(2); + } + + /** + * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String, + * BigDecimal) + */ + public static BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) { + return convertConfiguredAmountToApiAmount(currency, configuredAmount); + } + + /** + * Braintree’s API expects amounts in a currency’s primary unit (e.g. USD 4.99) + * + * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String, + * BigDecimal) + */ + static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) { + return convertConfiguredAmountToApiAmount(currency, amount); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java index 81e4a6b37..a76dc8593 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java @@ -36,7 +36,7 @@ public enum SubscriptionProcessor { private final byte id; SubscriptionProcessor(int id) { - if (id > 256) { + if (id > 255) { throw new IllegalArgumentException("ID must fit in one byte: " + id); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java index d26710ad9..71fd6f91d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -5,13 +5,16 @@ package org.whispersystems.textsecuregcm.subscriptions; +import java.math.BigDecimal; import java.time.Instant; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public interface SubscriptionProcessorManager { - SubscriptionProcessor getProcessor(); boolean supportsPaymentMethod(PaymentMethod paymentMethod); @@ -26,6 +29,32 @@ public interface SubscriptionProcessorManager { CompletableFuture createPaymentMethodSetupToken(String customerId); + + /** + * @param customerId + * @param paymentMethodToken a processor-specific token necessary + * @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update + * @return + */ + CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, + @Nullable String currentSubscriptionId); + + CompletableFuture getSubscription(String subscriptionId); + + CompletableFuture createSubscription(String customerId, String templateId, long level, + long lastSubscriptionCreatedAt); + + CompletableFuture updateSubscription( + Object subscription, String templateId, long level, String idempotencyKey); + + CompletableFuture getLevelForSubscription(Object subscription); + + CompletableFuture cancelAllActiveSubscriptions(String customerId); + + CompletableFuture getReceiptItem(String subscriptionId); + + CompletableFuture getSubscriptionInformation(Object subscription); + record PaymentDetails(String id, Map customMetadata, PaymentStatus status, @@ -39,4 +68,96 @@ public interface SubscriptionProcessorManager { FAILED, UNKNOWN, } + + 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 either failed or 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(SubscriptionProcessorManager.class); + logger.error("Subscription has status that should never happen: {}", status); + + yield UNKNOWN; + } + default -> { + final Logger logger = LoggerFactory.getLogger(SubscriptionProcessorManager.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, + ChargeFailure chargeFailure) { + + } + + record SubscriptionPrice(String currency, BigDecimal amount) { + + } + + record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus, + @Nullable String outcomeReason, @Nullable String outcomeType) { + + } + + record ReceiptItem(String itemId, Instant expiration, long level) { + + } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 3592d4725..c8db739f0 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.stripe.exception.ApiException; import com.stripe.model.PaymentIntent; -import com.stripe.model.Subscription; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; @@ -65,6 +64,7 @@ import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.util.SystemMapper; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -127,7 +127,6 @@ class SubscriptionControllerTest { @Test void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() { - when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(250)); when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true); final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") .request() @@ -150,24 +149,21 @@ class SubscriptionControllerTest { @Test void testCreateBoostPaymentIntentLevelAmountMismatch() { - when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(20)); - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") .request() .post(Entity.json(""" - { - "currency": "USD", - "amount": 25, - "level": 100 - } - """ + { + "currency": "USD", + "amount": 25, + "level": 100 + } + """ )); assertThat(response.getStatus()).isEqualTo(409); } @Test void testCreateBoostPaymentIntent() { - when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(300)); when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong())) .thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT)); when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true); @@ -233,7 +229,7 @@ class SubscriptionControllerTest { @Test void success() { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(mock(Subscription.class))); + .thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class))); final String level = String.valueOf(levelId); final String idempotencyKey = UUID.randomUUID().toString(); @@ -669,37 +665,55 @@ class SubscriptionControllerTest { prices: usd: amount: '5' - id: R1 + processorIds: + STRIPE: R1 + BRAINTREE: M1 jpy: amount: '500' - id: Q1 + processorIds: + STRIPE: Q1 + BRAINTREE: N1 bif: amount: '5000' - id: S1 + processorIds: + STRIPE: S1 + BRAINTREE: O1 15: badge: B2 prices: usd: amount: '15' - id: R2 + processorIds: + STRIPE: R2 + BRAINTREE: M2 jpy: amount: '1500' - id: Q2 + processorIds: + STRIPE: Q2 + BRAINTREE: N2 bif: amount: '15000' - id: S2 + processorIds: + STRIPE: S2 + BRAINTREE: O2 35: badge: B3 prices: usd: amount: '35' - id: R3 + processorIds: + STRIPE: R3 + BRAINTREE: M3 jpy: amount: '3500' - id: Q3 + processorIds: + STRIPE: Q3 + BRAINTREE: N3 bif: amount: '35000' - id: S3 + processorIds: + STRIPE: S3 + BRAINTREE: O3 """; private static final String ONETIME_CONFIG_YAML = """ diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java index 0898599e8..0a12db09d 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java @@ -40,6 +40,7 @@ import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; class SubscriptionManagerTest { private static final long NOW_EPOCH_SECONDS = 1_500_000_000L; + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3); private static final String SUBSCRIPTIONS_TABLE_NAME = "subscriptions"; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); @@ -95,13 +96,13 @@ class SubscriptionManagerTest { Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); CompletableFuture getFuture = subscriptionManager.get(user, password1); - assertThat(getFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult.type).isEqualTo(NOT_STORED); assertThat(getResult.record).isNull(); }); getFuture = subscriptionManager.get(user, password2); - assertThat(getFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult.type).isEqualTo(NOT_STORED); assertThat(getResult.record).isNull(); }); @@ -109,35 +110,35 @@ class SubscriptionManagerTest { CompletableFuture createFuture = subscriptionManager.create(user, password1, created1); Consumer recordRequirements = checkFreshlyCreatedRecord(user, password1, created1); - assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements); + assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements); // password check fails so this should return null createFuture = subscriptionManager.create(user, password2, created2); - assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).isNull(); + assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).isNull(); // password check matches, but the record already exists so nothing should get updated createFuture = subscriptionManager.create(user, password1, created2); - assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements); + assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements); } @Test void testGet() { byte[] wrongUser = getRandomBytes(16); byte[] wrongPassword = getRandomBytes(16); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult.type).isEqualTo(FOUND); assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created)); }); - assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(Duration.ofSeconds(3)) + assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(DEFAULT_TIMEOUT) .satisfies(getResult -> { assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH); assertThat(getResult.record).isNull(); }); - assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(Duration.ofSeconds(3)) + assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(DEFAULT_TIMEOUT) .satisfies(getResult -> { assertThat(getResult.type).isEqualTo(NOT_STORED); assertThat(getResult.record).isNull(); @@ -147,15 +148,15 @@ class SubscriptionManagerTest { @Test void testSetCustomerIdAndProcessor() throws Exception { Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); final CompletableFuture getUser = subscriptionManager.get(user, password); - assertThat(getUser).succeedsWithin(Duration.ofSeconds(3)); + assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT); final Record userRecord = getUser.get().record; assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord, new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3)) + subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT) .hasFieldOrPropertyWithValue("processorCustomer", Optional.of(new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))); @@ -166,7 +167,7 @@ class SubscriptionManagerTest { assertThat( subscriptionManager.setProcessorAndCustomerId(userRecord, new ProcessorCustomer(customer + "1", SubscriptionProcessor.STRIPE), - subscriptionUpdated)).failsWithin(Duration.ofSeconds(3)) + subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT) .withThrowableOfType(ExecutionException.class) .withCauseInstanceOf(ClientErrorException.class) .extracting(Throwable::getCause) @@ -176,7 +177,7 @@ class SubscriptionManagerTest { assertThat( subscriptionManager.setProcessorAndCustomerId(userRecord, new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).failsWithin(Duration.ofSeconds(3)) + subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT) .withThrowableOfType(ExecutionException.class) .withCauseInstanceOf(ClientErrorException.class) .extracting(Throwable::getCause) @@ -184,34 +185,34 @@ class SubscriptionManagerTest { assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer( new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))) - .succeedsWithin(Duration.ofSeconds(3)). + .succeedsWithin(DEFAULT_TIMEOUT). isEqualTo(user); } @Test void testLookupByCustomerId() throws Exception { Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); final CompletableFuture getUser = subscriptionManager.get(user, password); - assertThat(getUser).succeedsWithin(Duration.ofSeconds(3)); + assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT); final Record userRecord = getUser.get().record; assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord, new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3)); + subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT); assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer( new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))). - succeedsWithin(Duration.ofSeconds(3)). + succeedsWithin(DEFAULT_TIMEOUT). isEqualTo(user); } @Test void testCanceledAt() { Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); - assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(Duration.ofSeconds(3)); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); + assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT); + assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult).isNotNull(); assertThat(getResult.type).isEqualTo(FOUND); assertThat(getResult.record).isNotNull().satisfies(record -> { @@ -227,10 +228,10 @@ class SubscriptionManagerTest { String subscriptionId = Base64.getEncoder().encodeToString(getRandomBytes(16)); Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); long level = 42; - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); assertThat(subscriptionManager.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)). - succeedsWithin(Duration.ofSeconds(3)); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + succeedsWithin(DEFAULT_TIMEOUT); + assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult).isNotNull(); assertThat(getResult.type).isEqualTo(FOUND); assertThat(getResult.record).isNotNull().satisfies(record -> { @@ -247,15 +248,20 @@ class SubscriptionManagerTest { void testSubscriptionLevelChanged() { Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500); long level = 1776; - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3)); - assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level)).succeedsWithin(Duration.ofSeconds(3)); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> { + String updatedSubscriptionId = "new"; + assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); + assertThat(subscriptionManager.subscriptionCreated(user, "original", created, level - 1)).succeedsWithin( + DEFAULT_TIMEOUT); + assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level, updatedSubscriptionId)).succeedsWithin( + DEFAULT_TIMEOUT); + assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { assertThat(getResult).isNotNull(); assertThat(getResult.type).isEqualTo(FOUND); assertThat(getResult.record).isNotNull().satisfies(record -> { assertThat(record.accessedAt).isEqualTo(at); assertThat(record.subscriptionLevelChangedAt).isEqualTo(at); assertThat(record.subscriptionLevel).isEqualTo(level); + assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId); }); }); }