diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 7c966e25d..cd9dcb313 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -176,6 +176,7 @@ import org.whispersystems.textsecuregcm.grpc.MessageDispatcher; import org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.MessagesGrpcService; import org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor; +import org.whispersystems.textsecuregcm.grpc.OneTimeDonationsGrpcService; import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService; import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService; @@ -1095,7 +1096,10 @@ public class WhisperServerService extends Application ServerInterceptors.intercept(bindableService, // Note: interceptors run in the reverse order they are added; the remote deprecation filter // depends on the user-agent context so it has to come first here! diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java index 3a4a9af1b..f5f036366 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java @@ -39,6 +39,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; @@ -55,6 +56,7 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.DonationPermitHeader; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; +import org.whispersystems.textsecuregcm.grpc.OneTimeDonationUtil; import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil; import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; import org.whispersystems.textsecuregcm.limits.RateLimiters; @@ -72,11 +74,9 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; import org.whispersystems.textsecuregcm.util.ExactlySize; import org.whispersystems.textsecuregcm.util.HeaderUtils; - /** * Endpoints for making one-time donation payments (boost and gift) *

@@ -90,8 +90,6 @@ public class OneTimeDonationController { private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class); - private static final String EURO_CURRENCY_CODE = "EUR"; - private final Clock clock; private final OneTimeDonationConfiguration oneTimeDonationConfiguration; private final StripeManager stripeManager; @@ -103,15 +101,15 @@ public class OneTimeDonationController { private final DonationPermitsManager donationPermitsManager; public OneTimeDonationController( - Clock clock, - OneTimeDonationConfiguration oneTimeDonationConfiguration, - StripeManager stripeManager, - BraintreeManager braintreeManager, - PayPalDonationsTranslator payPalDonationsTranslator, - ServerZkReceiptOperations zkReceiptOperations, - IssuedReceiptsManager issuedReceiptsManager, - OneTimeDonationsManager oneTimeDonationsManager, - DonationPermitsManager donationPermitsManager) { + final Clock clock, + final OneTimeDonationConfiguration oneTimeDonationConfiguration, + final StripeManager stripeManager, + final BraintreeManager braintreeManager, + final PayPalDonationsTranslator payPalDonationsTranslator, + final ServerZkReceiptOperations zkReceiptOperations, + final IssuedReceiptsManager issuedReceiptsManager, + final OneTimeDonationsManager oneTimeDonationsManager, + final DonationPermitsManager donationPermitsManager) { this.clock = Objects.requireNonNull(clock); this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); this.stripeManager = Objects.requireNonNull(stripeManager); @@ -151,10 +149,10 @@ public class OneTimeDonationController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Create a Stripe payment intent", description = """ - Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment. - - Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials - """) + Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment. + + Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials + """) @ApiResponse(responseCode = "200", description = "Payment Intent created", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class))) @ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel") @ApiResponse(responseCode = "400", description = """ @@ -171,13 +169,12 @@ public class OneTimeDonationController { @ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent") @RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION) public CompletableFuture createBoostPaymentIntent( - @Auth Optional authenticatedAccount, + @Auth final Optional authenticatedAccount, - @Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit") - @HeaderParam(HeaderUtils.DONATION_PERMIT) - final Optional donationPermitHeader, + @Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit") + @HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional donationPermitHeader, - @NotNull @Valid CreateBoostRequest request, + @NotNull @Valid final CreateBoostRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { if (authenticatedAccount.isPresent()) { @@ -189,7 +186,7 @@ public class OneTimeDonationController { permitHeader -> { try { return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock); - } catch (VerificationFailedException e) { + } catch (final VerificationFailedException e) { return false; } }) @@ -206,47 +203,34 @@ public class OneTimeDonationController { } /** - * Validates that the request level is valid, the currency is supported by the {@code manager} and {@code request.paymentMethod}, - * and that the amount meets minimum and maximum constraints. + * Validates that the request level is valid, the currency is supported by the {@code manager} and + * {@code request.paymentMethod}, and that the amount meets minimum and maximum constraints. * * @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details */ private void validateRequestCurrencyAmount(final CreateBoostRequest request, final BigDecimal amount, final CustomerAwareSubscriptionPaymentProcessor manager) { - if (!(request.level == oneTimeDonationConfiguration.gift().level() - || request.level == oneTimeDonationConfiguration.boost().level())) { + final Map errorBody = switch (OneTimeDonationUtil.validateOneTimeDonationRequest(request.currency, + amount, request.level, request.paymentMethod, oneTimeDonationConfiguration, manager)) { + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ -> + Map.of("error", "invalid_level"); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ -> + Map.of("error", "unsupported_currency"); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum(final BigDecimal min) -> + Map.of("error", "amount_below_currency_minimum", + "minimum", min.toString()); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(final BigDecimal max) -> + Map.of("error", "amount_above_sepa_limit", + "maximum", max.toString()); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> Collections.emptyMap(); + }; + + if (!errorBody.isEmpty()) { throw new BadRequestException( - Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).build()); + Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build()); } - if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod) - .contains(request.currency.toLowerCase(Locale.ROOT))) { - throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) - .entity(Map.of("error", "unsupported_currency")).build()); - } - - final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() - .get(request.currency.toLowerCase(Locale.ROOT)).minimum(); - final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( - request.currency, - minCurrencyAmountMajorUnits); - if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) { - throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) - .entity(Map.of( - "error", "amount_below_currency_minimum", - "minimum", minCurrencyAmountMajorUnits.toString())).build()); - } - - if (request.paymentMethod == PaymentMethod.SEPA_DEBIT && - amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( - EURO_CURRENCY_CODE, - oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) { - throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) - .entity(Map.of( - "error", "amount_above_sepa_limit", - "maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build()); - } } public static class CreatePayPalBoostRequest extends CreateBoostRequest { @@ -268,9 +252,9 @@ public class OneTimeDonationController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public CompletableFuture createPayPalBoost( - @Auth Optional authenticatedAccount, - @NotNull @Valid CreatePayPalBoostRequest request, - @Context ContainerRequestContext containerRequestContext) { + @Auth final Optional authenticatedAccount, + @NotNull @Valid final CreatePayPalBoostRequest request, + @Context final ContainerRequestContext containerRequestContext) { if (authenticatedAccount.isPresent()) { throw new ForbiddenException("must not use authenticated connection for one-time donation operations"); @@ -281,21 +265,11 @@ public class OneTimeDonationController { .thenCompose(_ -> { final List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); - - // These two localizations are a best-effort, and it's possible that the first `locale` and the localized line - // item name will not match. We could try to align with the locales PayPal documents - // but that's a moving target, and we can hopefully have one of them be better for the user by selecting - // independently. - final Locale locale = acceptableLanguages.stream() - .filter(l -> !"*".equals(l.getLanguage())) - .findFirst() - .orElse(Locale.US); - final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages, - PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY); - + final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem( + payPalDonationsTranslator, acceptableLanguages); return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount, - locale.toLanguageTag(), - request.returnUrl, request.cancelUrl, localizedLineItemName); + localizedLineItem.locale().toLanguageTag(), + request.returnUrl, request.cancelUrl, localizedLineItem.itemName()); }) .thenApply(approvalDetails -> Response.ok( new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build()); @@ -322,8 +296,8 @@ public class OneTimeDonationController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public CompletableFuture confirmPayPalBoost( - @Auth Optional authenticatedAccount, - @NotNull @Valid ConfirmPayPalBoostRequest request, + @Auth final Optional authenticatedAccount, + @NotNull @Valid final ConfirmPayPalBoostRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { if (authenticatedAccount.isPresent()) { @@ -366,7 +340,7 @@ public class OneTimeDonationController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public CompletableFuture createBoostReceiptCredentials( - @Auth Optional authenticatedAccount, + @Auth final Optional authenticatedAccount, @NotNull @Valid final CreateBoostReceiptCredentialsRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { @@ -374,68 +348,56 @@ public class OneTimeDonationController { throw new ForbiddenException("must not use authenticated connection for one-time donation operations"); } - final CompletableFuture paymentDetailsFut = switch (request.processor) { + final CompletableFuture> paymentDetailsFut = switch (request.processor) { case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId); case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId); case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations"); case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations"); }; - return paymentDetailsFut.thenApply(paymentDetails -> { - if (paymentDetails == null) { + return paymentDetailsFut.thenApply(maybePaymentDetails -> { + if (maybePaymentDetails.isEmpty()) { throw new WebApplicationException(Response.Status.NOT_FOUND); - } else if (paymentDetails.status() == PaymentStatus.PROCESSING) { + } + final PaymentDetails paymentDetails = maybePaymentDetails.get(); + if (paymentDetails.status() == PaymentStatus.PROCESSING) { return Response.noContent().build(); - } else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) { + } + if (paymentDetails.status() != PaymentStatus.SUCCEEDED) { throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED) .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build()); } // The payment was successful, try to issue the receipt credential - long level = oneTimeDonationConfiguration.boost().level(); - if (paymentDetails.customMetadata() != null) { - String levelMetadata = paymentDetails.customMetadata() - .getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level())); - try { - level = Long.parseLong(levelMetadata); - } catch (NumberFormatException e) { - logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, - paymentDetails.id(), e); - throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR); - } - } - Duration levelExpiration; - if (oneTimeDonationConfiguration.boost().level() == level) { - levelExpiration = oneTimeDonationConfiguration.boost().expiration(); - } else if (oneTimeDonationConfiguration.gift().level() == level) { - levelExpiration = oneTimeDonationConfiguration.gift().expiration(); - } else { - logger.error("level ({}) returned from payment intent that is unknown to the server", level); + final OneTimeDonationUtil.DonationLevelDetails levelDetails; + try { + levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration); + } catch (OneTimeDonationUtil.InvalidLevelException _) { throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR); } - ReceiptCredentialRequest receiptCredentialRequest; + + final ReceiptCredentialRequest receiptCredentialRequest; try { receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest); - } catch (InvalidInputException e) { + } catch (final InvalidInputException e) { throw new BadRequestException("invalid receipt credential request", e); } - final long finalLevel = level; try { issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor, receiptCredentialRequest, clock.instant()); - } catch (final WriteConflictException _) { + } catch (WriteConflictException _) { throw new WebApplicationException(Response.Status.CONFLICT); } final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created()); final Instant expiration = paidAt - .plus(levelExpiration) + .plus(levelDetails.levelExpiration()) .truncatedTo(ChronoUnit.DAYS) .plus(1, ChronoUnit.DAYS); final ReceiptCredentialResponse receiptCredentialResponse; try { receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( - receiptCredentialRequest, expiration.getEpochSecond(), finalLevel); + receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level()); } catch (final VerificationFailedException e) { throw new BadRequestException("receipt credential request failed verification", e); } 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 fcc4ae63c..f142bd075 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers; import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildCurrencyConfiguration; import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildDonationLevelsConfiguration; import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform; +import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -318,10 +319,7 @@ public class SubscriptionController { final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock); - final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream() - .filter(l -> !"*".equals(l.getLanguage())) - .findFirst() - .orElse(Locale.US); + final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext)); final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer( subscriberCredentials, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationUtil.java new file mode 100644 index 000000000..91ba14dcc --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationUtil.java @@ -0,0 +1,130 @@ +package org.whispersystems.textsecuregcm.grpc; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; +import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor; +import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; +import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails; +import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; + +public class OneTimeDonationUtil { + + private static final String EURO_CURRENCY_CODE = "EUR"; + + private static final Logger LOGGER = LoggerFactory.getLogger(OneTimeDonationUtil.class); + + /// Thrown if a one time donation level cannot be parsed or if it is not found in configuration + public static class InvalidLevelException extends Exception { + + public InvalidLevelException(final String message) { + super(message); + } + } + + public record LocalizedPayPalDonationLineItem(Locale locale, String itemName){} + public record DonationLevelDetails(long level, Duration levelExpiration){} + + public sealed interface OneTimeDonationRequestValidationResult permits OneTimeDonationRequestValidationResult.Success, + OneTimeDonationRequestValidationResult.UnsupportedCurrency, + OneTimeDonationRequestValidationResult.UnsupportedLevel, + OneTimeDonationRequestValidationResult.AmountBelowMinimum, + OneTimeDonationRequestValidationResult.AmountAboveSepaLimit { + + record Success() implements OneTimeDonationRequestValidationResult {} + + record UnsupportedCurrency() implements OneTimeDonationRequestValidationResult {} + + record UnsupportedLevel() implements OneTimeDonationRequestValidationResult {} + + record AmountBelowMinimum(BigDecimal minimum) implements OneTimeDonationRequestValidationResult {} + + record AmountAboveSepaLimit(BigDecimal maximum) implements OneTimeDonationRequestValidationResult {} + + } + + public static OneTimeDonationRequestValidationResult validateOneTimeDonationRequest( + final String currency, + final BigDecimal amount, + final long level, + final PaymentMethod paymentMethod, + final OneTimeDonationConfiguration oneTimeDonationConfiguration, + final CustomerAwareSubscriptionPaymentProcessor manager + ) { + + if (!(level == oneTimeDonationConfiguration.gift().level() + || level == oneTimeDonationConfiguration.boost().level())) { + return new OneTimeDonationRequestValidationResult.UnsupportedLevel(); + } + + if (!manager.getSupportedCurrenciesForPaymentMethod(paymentMethod) + .contains(currency.toLowerCase(Locale.ROOT))) { + return new OneTimeDonationRequestValidationResult.UnsupportedCurrency(); + } + + final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() + .get(currency.toLowerCase(Locale.ROOT)).minimum(); + final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( + currency, + minCurrencyAmountMajorUnits); + if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) { + return new OneTimeDonationRequestValidationResult.AmountBelowMinimum(minCurrencyAmountMajorUnits); + } + + if (paymentMethod == PaymentMethod.SEPA_DEBIT && + amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( + EURO_CURRENCY_CODE, + oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) { + return new OneTimeDonationRequestValidationResult.AmountAboveSepaLimit( + oneTimeDonationConfiguration.sepaMaximumEuros()); + } + return new OneTimeDonationRequestValidationResult.Success(); + } + + public static LocalizedPayPalDonationLineItem localizePayPalDonationLineItem( + final PayPalDonationsTranslator payPalDonationsTranslator, final List acceptableLocales) { + // These two localizations are a best-effort, and it's possible that the first `locale` and the localized line + // item name will not match. We could try to align with the locales PayPal documents + // but that's a moving target, and we can hopefully have one of them be better for the user by selecting + // independently. + final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales); + final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales, + org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY); + return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName); + } + + public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails, + final OneTimeDonationConfiguration oneTimeDonationConfiguration) + throws InvalidLevelException { + + long level = oneTimeDonationConfiguration.boost().level(); + if (paymentDetails.customMetadata() != null) { + final String levelMetadata = paymentDetails.customMetadata() + .getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level())); + try { + level = Long.parseLong(levelMetadata); + } catch (final NumberFormatException e) { + LOGGER.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, + paymentDetails.id(), e); + throw new InvalidLevelException("failed to parse level metadata"); + } + } + + final Duration levelExpiration; + if (level == oneTimeDonationConfiguration.boost().level()) { + levelExpiration = oneTimeDonationConfiguration.boost().expiration(); + } else if (level == oneTimeDonationConfiguration.gift().level()) { + levelExpiration = oneTimeDonationConfiguration.gift().expiration(); + } else { + LOGGER.error("level ({}) returned from payment intent that is unknown to the server", level); + throw new InvalidLevelException("unrecognized level"); + } + return new DonationLevelDetails(level, levelExpiration); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcService.java new file mode 100644 index 000000000..bfadca97d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcService.java @@ -0,0 +1,326 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.grpc; + +import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform; +import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.toChargeFailure; + +import com.google.protobuf.ByteString; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import org.signal.chat.errors.FailedPrecondition; +import org.signal.chat.errors.FailedZkAuthentication; +import org.signal.chat.errors.NotFound; +import org.signal.chat.one_time_donations.AmountAboveSepaLimitError; +import org.signal.chat.one_time_donations.AmountBelowMinimumError; +import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest; +import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse; +import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest; +import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse; +import org.signal.chat.one_time_donations.CreateBoostRequest; +import org.signal.chat.one_time_donations.CreateBoostResponse; +import org.signal.chat.one_time_donations.CreatePayPalBoostRequest; +import org.signal.chat.one_time_donations.CreatePayPalBoostResponse; +import org.signal.chat.one_time_donations.SimpleOneTimeDonationsGrpc; +import org.signal.chat.subscriptions.PaymentRequired; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.donation.DonationPermit; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; +import org.whispersystems.textsecuregcm.storage.DonationPermitsManager; +import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; +import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; +import org.whispersystems.textsecuregcm.storage.WriteConflictException; +import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; +import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; +import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; +import org.whispersystems.textsecuregcm.subscriptions.StripeManager; + +public class OneTimeDonationsGrpcService extends SimpleOneTimeDonationsGrpc.OneTimeDonationsImplBase { + + private final OneTimeDonationConfiguration oneTimeDonationConfiguration; + private final StripeManager stripeManager; + private final BraintreeManager braintreeManager; + private final PayPalDonationsTranslator payPalDonationsTranslator; + private final OneTimeDonationsManager oneTimeDonationsManager; + private final IssuedReceiptsManager issuedReceiptsManager; + private final ServerZkReceiptOperations zkReceiptOperations; + private final Clock clock; + private final RateLimiters rateLimiters; + private final DonationPermitsManager donationPermitsManager; + + public OneTimeDonationsGrpcService( + final OneTimeDonationConfiguration oneTimeDonationConfiguration, + final StripeManager stripeManager, + final BraintreeManager braintreeManager, + final PayPalDonationsTranslator payPalDonationsTranslator, + final OneTimeDonationsManager oneTimeDonationsManager, + final IssuedReceiptsManager issuedReceiptsManager, + final ServerZkReceiptOperations zkReceiptOperations, + final Clock clock, + final RateLimiters rateLimiters, + final DonationPermitsManager donationPermitsManager) { + this.oneTimeDonationConfiguration = oneTimeDonationConfiguration; + this.stripeManager = stripeManager; + this.braintreeManager = braintreeManager; + this.payPalDonationsTranslator = payPalDonationsTranslator; + this.oneTimeDonationsManager = oneTimeDonationsManager; + this.issuedReceiptsManager = issuedReceiptsManager; + this.zkReceiptOperations = zkReceiptOperations; + this.clock = clock; + this.rateLimiters = rateLimiters; + this.donationPermitsManager = donationPermitsManager; + } + + @Override + public CreateBoostResponse createBoost(final CreateBoostRequest request) throws RateLimitExceededException { + RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)); + + try { + final DonationPermit donationPermit = new DonationPermit(request.getDonationPermit().toByteArray()); + if (!donationPermitsManager.spend(donationPermit)) { + return CreateBoostResponse.newBuilder() + .setPermitRejected(FailedZkAuthentication.newBuilder() + .setDescription("donation permit rejected") + .build()) + .build(); + } + } catch (InvalidInputException | VerificationFailedException _) { + return CreateBoostResponse.newBuilder() + .setPermitRejected(FailedZkAuthentication.newBuilder() + .setDescription("donation permit rejected") + .build()) + .build(); + } + + final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod = + switch (request.getPaymentMethod()) { + case PAYMENT_METHOD_CARD -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD; + case PAYMENT_METHOD_SEPA_DEBIT -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT; + case PAYMENT_METHOD_IDEAL -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.IDEAL; + default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method"); + }; + + final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult = + OneTimeDonationUtil.validateOneTimeDonationRequest( + request.getCurrency(), + BigDecimal.valueOf(request.getAmount()), + request.getLevel(), + paymentMethod, + oneTimeDonationConfiguration, + stripeManager); + + return switch (validationResult) { + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ -> + CreateBoostResponse.newBuilder() + .setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ -> + CreateBoostResponse.newBuilder() + .setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build(); + case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r -> + CreateBoostResponse.newBuilder() + .setAmountBelowMinimum(AmountBelowMinimumError.newBuilder() + .setMinimum(r.minimum().toString()).build()).build(); + case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit r -> + CreateBoostResponse.newBuilder() + .setAmountAboveSepaLimit(AmountAboveSepaLimitError.newBuilder() + .setMaximum(r.maximum().toString()).build()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> { + final com.stripe.model.PaymentIntent paymentIntent = stripeManager.createPaymentIntent( + request.getCurrency(), request.getAmount(), request.getLevel(), + getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join(); + yield CreateBoostResponse.newBuilder().setClientSecret(paymentIntent.getClientSecret()).build(); + } + }; + } + + @Override + public CreatePayPalBoostResponse createPayPalBoost(final CreatePayPalBoostRequest request) { + + final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult = + OneTimeDonationUtil.validateOneTimeDonationRequest( + request.getCurrency(), + BigDecimal.valueOf(request.getAmount()), + request.getLevel(), + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL, + oneTimeDonationConfiguration, + braintreeManager); + + return switch (validationResult) { + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ -> + CreatePayPalBoostResponse.newBuilder() + .setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ -> + CreatePayPalBoostResponse.newBuilder() + .setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build(); + case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r -> + CreatePayPalBoostResponse.newBuilder() + .setAmountBelowMinimum(AmountBelowMinimumError.newBuilder() + .setMinimum(r.minimum().toString()).build()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ -> + throw new IllegalStateException("SEPA limit should not trigger for PayPal"); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> { + final List acceptableLocales = RequestAttributesUtil.getAvailableAcceptedLocales(); + final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem( + payPalDonationsTranslator, acceptableLocales); + final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails = + braintreeManager.createOneTimePayment( + request.getCurrency().toUpperCase(Locale.ROOT), request.getAmount(), + localizedLineItem.locale().toLanguageTag(), request.getReturnUrl(), request.getCancelUrl(), + localizedLineItem.itemName()).join(); + yield CreatePayPalBoostResponse.newBuilder() + .setResult(CreatePayPalBoostResponse.CreatePayPalBoostResult.newBuilder() + .setApprovalUrl(approvalDetails.approvalUrl()) + .setPaymentId(approvalDetails.paymentId()).build()).build(); + } + }; + } + + @Override + public ConfirmPayPalBoostResponse confirmPayPalBoost(final ConfirmPayPalBoostRequest request) + throws RateLimitExceededException { + RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)); + + final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult = + OneTimeDonationUtil.validateOneTimeDonationRequest( + request.getCurrency(), + BigDecimal.valueOf(request.getAmount()), + request.getLevel(), + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL, + oneTimeDonationConfiguration, + braintreeManager); + + return switch (validationResult) { + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ -> + ConfirmPayPalBoostResponse.newBuilder() + .setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ -> + ConfirmPayPalBoostResponse.newBuilder() + .setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build(); + case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r -> + ConfirmPayPalBoostResponse.newBuilder() + .setAmountBelowMinimum(AmountBelowMinimumError.newBuilder() + .setMinimum(r.minimum().toString()).build()).build(); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ -> + throw new IllegalStateException("SEPA limit should not trigger for PayPal"); + case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> { + final BraintreeManager.PayPalChargeSuccessDetails chargeSuccessDetails = + braintreeManager.captureOneTimePayment( + request.getPayerId(), request.getPaymentId(), request.getPaymentToken(), + request.getCurrency(), request.getAmount(), request.getLevel(), + getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join(); + oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), clock.instant()); + yield ConfirmPayPalBoostResponse.newBuilder() + .setResult(ConfirmPayPalBoostResponse.ConfirmPayPalBoostResult.newBuilder() + .setPaymentId(chargeSuccessDetails.paymentId()).build()).build(); + } + }; + } + + @Override + public CreateBoostReceiptCredentialsResponse createBoostReceiptCredentials( + final CreateBoostReceiptCredentialsRequest request) { + + final PaymentProvider processor; + final Optional maybePaymentDetails; + switch (request.getProcessor()) { + case PAYMENT_PROVIDER_STRIPE -> { + processor = PaymentProvider.STRIPE; + maybePaymentDetails = stripeManager.getPaymentDetails(request.getPaymentIntentId()).join(); + } + case PAYMENT_PROVIDER_BRAINTREE -> { + processor = PaymentProvider.BRAINTREE; + maybePaymentDetails = braintreeManager.getPaymentDetails(request.getPaymentIntentId()).join(); + } + default -> throw GrpcExceptions.fieldViolation("processor", "Unsupported payment processor"); + } + + if (maybePaymentDetails.isEmpty()) { + return CreateBoostReceiptCredentialsResponse.newBuilder() + .setPaymentNotFound(NotFound.getDefaultInstance()).build(); + } + final PaymentDetails paymentDetails = maybePaymentDetails.get(); + if (paymentDetails.status() == PaymentStatus.PROCESSING) { + return CreateBoostReceiptCredentialsResponse.newBuilder() + .setPaymentStillProcessing(FailedPrecondition.getDefaultInstance()).build(); + } + if (paymentDetails.status() != PaymentStatus.SUCCEEDED) { + final PaymentRequired.Builder paymentRequiredBuilder = PaymentRequired.newBuilder(); + if (paymentDetails.chargeFailure() != null) { + paymentRequiredBuilder.setChargeFailure(toChargeFailure(processor, paymentDetails.chargeFailure())); + } + return CreateBoostReceiptCredentialsResponse.newBuilder() + .setPaymentRequired(paymentRequiredBuilder).build(); + } + + final OneTimeDonationUtil.DonationLevelDetails levelDetails; + try { + levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration); + } catch (final OneTimeDonationUtil.InvalidLevelException e) { + throw GrpcExceptions.unavailable(e.getMessage()); + } + + final ReceiptCredentialRequest receiptCredentialRequest; + try { + receiptCredentialRequest = new ReceiptCredentialRequest( + request.getReceiptCredentialRequest().toByteArray()); + } catch (final InvalidInputException e) { + throw GrpcExceptions.fieldViolation("receipt_credential_request", "invalid receipt credential request"); + } + + try { + issuedReceiptsManager.recordIssuance( + paymentDetails.id(), processor, receiptCredentialRequest, clock.instant()); + } catch (final WriteConflictException e) { + return CreateBoostReceiptCredentialsResponse.newBuilder() + .setReceiptAlreadyIssued(FailedPrecondition.getDefaultInstance()).build(); + } + + final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created()); + final Instant expiration = paidAt + .plus(levelDetails.levelExpiration()) + .truncatedTo(ChronoUnit.DAYS) + .plus(1, ChronoUnit.DAYS); + + final ReceiptCredentialResponse receiptCredentialResponse; + try { + receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( + receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level()); + } catch (final VerificationFailedException e) { + throw GrpcExceptions.fieldViolation("receipt_credential_request", + "receipt credential request failed verification"); + } + + Metrics.counter(SubscriptionsGrpcService.RECEIPT_ISSUED_COUNTER_NAME, + Tags.of( + Tag.of(SubscriptionsGrpcService.PROCESSOR_TAG_NAME, processor.toString()), + Tag.of(SubscriptionsGrpcService.TYPE_TAG_NAME, "boost"), + UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null)))) + .increment(); + + return CreateBoostReceiptCredentialsResponse.newBuilder() + .setResult(CreateBoostReceiptCredentialsResponse.CreateBoostReceiptCredentialsResult.newBuilder() + .setReceiptCredentialResponse(ByteString.copyFrom(receiptCredentialResponse.serialize())) + .build()) + .build(); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcService.java index 91569d829..736eab009 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcService.java @@ -1,6 +1,7 @@ package org.whispersystems.textsecuregcm.grpc; import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform; +import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale; import com.google.protobuf.ByteString; import com.google.protobuf.Empty; @@ -11,6 +12,9 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; import org.signal.chat.errors.FailedPrecondition; import org.signal.chat.errors.FailedUnidentifiedAuthorization; import org.signal.chat.errors.FailedZkAuthentication; @@ -30,6 +34,7 @@ import org.signal.chat.subscriptions.GetReceiptCredentialsResponse; import org.signal.chat.subscriptions.GetSubscriptionInformationRequest; import org.signal.chat.subscriptions.GetSubscriptionInformationResponse; import org.signal.chat.subscriptions.PaymentMethod; +import org.signal.chat.subscriptions.PaymentRequired; import org.signal.chat.subscriptions.SetDefaultPaymentMethodRequest; import org.signal.chat.subscriptions.SetDefaultPaymentMethodResponse; import org.signal.chat.subscriptions.SetIapSubscriptionRequest; @@ -50,6 +55,8 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.PurchasableBadge; +import org.whispersystems.textsecuregcm.metrics.MetricsUtil; +import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.DonationPermitsManager; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.SubscriberCredentials; @@ -59,11 +66,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BankTransferType; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; -import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration; import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; -import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException; @@ -97,6 +102,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti private final BankMandateTranslator bankMandateTranslator; private final DynamicConfigurationManager dynamicConfigurationManager; + static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionsGrpcService.class, "receiptIssued"); + static final String PROCESSOR_TAG_NAME = "processor"; + static final String TYPE_TAG_NAME = "type"; + private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType"; + public SubscriptionsGrpcService(final Clock clock, final SubscriptionConfiguration subscriptionConfiguration, final OneTimeDonationConfiguration oneTimeDonationConfiguration, final SubscriptionManager subscriptionManager, final DonationPermitsManager donationPermitsManager, final StripeManager stripeManager, @@ -136,7 +146,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti } } catch (final InvalidInputException _) { throw GrpcExceptions.invalidArguments("invalid donation permit"); - } catch (final VerificationFailedException _ ) { + } catch (final VerificationFailedException _) { return UpdateSubscriberResponse.newBuilder() .setPermitRejected(FailedZkAuthentication.newBuilder() .setDescription("donation permit failed verification") @@ -198,7 +208,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti } } catch (final InvalidInputException _) { throw GrpcExceptions.invalidArguments("invalid donation permit"); - } catch (final VerificationFailedException _ ) { + } catch (final VerificationFailedException _) { return CreatePaymentMethodResponse.newBuilder() .setPermitRejected(FailedZkAuthentication.getDefaultInstance()) .build(); @@ -227,8 +237,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti public CreatePayPalPaymentMethodResponse createPayPalPaymentMethod(final CreatePayPalPaymentMethodRequest request) { final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process( request.getSubscriberId().toByteArray(), clock); - final Locale locale = RequestAttributesUtil.getAcceptableLanguages().stream().filter(r -> !"*".equals(r.getRange())) - .findFirst().map(r -> Locale.forLanguageTag(r.getRange())).orElse(Locale.US); + final Locale locale = getPayPalLocale(RequestAttributesUtil.getAvailableAcceptedLocales()); try { final BraintreeManager.PayPalBillingAgreementApprovalDetails details = subscriptionManager.addPaymentMethodToCustomer( subscriberCredentials, braintreeManager, getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)), @@ -363,7 +372,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti .build(); } catch (final SubscriptionProcessorException e) { return SetSubscriptionLevelResponse.newBuilder() - .setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build(); + .setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build(); } catch (final SubscriptionPaymentRequiresActionException e) { return SetSubscriptionLevelResponse.newBuilder().setPaymentRequiresAction(FailedPrecondition.newBuilder().build()) .build(); @@ -443,7 +452,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti subscription.setBillingCycleAnchor(info.billingCycleAnchor().getEpochSecond()); } if (info.chargeFailure() != null) { - subscription.setChargeFailure(toChargeFailure(info.paymentProvider(), info.chargeFailure())); + subscription.setChargeFailure(SubscriptionsUtil.toChargeFailure(info.paymentProvider(), info.chargeFailure())); } return GetSubscriptionInformationResponse.newBuilder().setSuccess(subscription.build()).build(); @@ -458,6 +467,15 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials( subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(), r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r)); + Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME, + Tags.of( + Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()), + Tag.of(TYPE_TAG_NAME, "subscription"), + Tag.of(SUBSCRIPTION_TYPE_TAG_NAME, + subscriptionConfiguration.getSubscriptionLevel(result.receiptItem().level()).type().name() + .toLowerCase(Locale.ROOT)), + UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null)))) + .increment(); return GetReceiptCredentialsResponse.newBuilder().setSuccess( GetReceiptCredentialsResponse.GetReceiptCredentialsResult.newBuilder() .setReceiptCredentialResponse(ByteString.copyFrom(result.receiptCredentialResponse().serialize())) @@ -467,11 +485,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti .build(); } catch (final SubscriptionChargeFailurePaymentRequiredException e) { return GetReceiptCredentialsResponse.newBuilder().setPaymentRequired( - GetReceiptCredentialsResponse.PaymentRequired.newBuilder() - .setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build(); + PaymentRequired.newBuilder() + .setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build(); } catch (final SubscriptionPaymentRequiredException e) { return GetReceiptCredentialsResponse.newBuilder() - .setPaymentRequired(GetReceiptCredentialsResponse.PaymentRequired.newBuilder().build()).build(); + .setPaymentRequired(PaymentRequired.newBuilder().build()).build(); } catch (final SubscriptionInvalidArgumentsException e) { throw GrpcExceptions.invalidArguments(e.errorDetail().orElse("")); } catch (final SubscriptionReceiptAlreadyRedeemedException e) { @@ -589,20 +607,4 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti return GetBankMandateResponse.newBuilder().setMandate(mandate).build(); } - private static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor, - final ChargeFailure chargeFailure) { - final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder() - .setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message()); - if (chargeFailure.outcomeNetworkStatus() != null) { - builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus()); - } - if (chargeFailure.outcomeReason() != null) { - builder.setOutcomeReason(chargeFailure.outcomeReason()); - } - if (chargeFailure.outcomeType() != null) { - builder.setOutcomeType(chargeFailure.outcomeType()); - } - return builder.build(); - } - } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsUtil.java index e13a01dc2..c73ef7e35 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsUtil.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsUtil.java @@ -25,10 +25,12 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.DonationPermitsManager; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration; import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor; import org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration; import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; @@ -160,4 +162,26 @@ public class SubscriptionsUtil { .increment(); } + public static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor, + final ChargeFailure chargeFailure) { + final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder() + .setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message()); + if (chargeFailure.outcomeNetworkStatus() != null) { + builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus()); + } + if (chargeFailure.outcomeReason() != null) { + builder.setOutcomeReason(chargeFailure.outcomeReason()); + } + if (chargeFailure.outcomeType() != null) { + builder.setOutcomeType(chargeFailure.outcomeType()); + } + return builder.build(); + } + + public static Locale getPayPalLocale(final List acceptableLocales) { + return acceptableLocales.stream() + .filter(l -> !"*".equals(l.getLanguage())) + .findFirst() + .orElse(Locale.US); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DonationPermitsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DonationPermitsManager.java index 662358019..265450bf9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DonationPermitsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DonationPermitsManager.java @@ -28,6 +28,7 @@ public class DonationPermitsManager { /// Verifies and then spends the given {@link DonationPermit} /// + /// @return whether the spend was successful /// @throws VerificationFailedException if the permit was not valid (expired or otherwise) public boolean spend(final DonationPermit donationPermit) throws VerificationFailedException { // Permits must be verified with the key pair that issued them, which can be re-derived from the embedded expiration 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 527f850ac..918fcf7fb 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -128,7 +128,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess return paymentMethod == PaymentMethod.PAYPAL; } - public CompletableFuture getPaymentDetails(final String paymentId) { + public CompletableFuture> getPaymentDetails(final String paymentId) { return CompletableFuture.supplyAsync(() -> { try { final Transaction transaction = braintreeGateway.transaction().find(paymentId); @@ -136,14 +136,14 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { chargeFailure = createChargeFailure(transaction); } - return new PaymentDetails(transaction.getGraphQLId(), + return Optional.of(new PaymentDetails(transaction.getGraphQLId(), transaction.getCustomFields(), getPaymentStatus(transaction.getStatus()), transaction.getCreatedAt().toInstant(), - chargeFailure); + chargeFailure)); } catch (final NotFoundException e) { - return null; + return Optional.empty(); } }, executor); } 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 e65584de6..7469ca531 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -242,7 +242,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor }, executor); } - public CompletableFuture getPaymentDetails(String paymentIntentId) { + public CompletableFuture> getPaymentDetails(final String paymentIntentId) { return CompletableFuture.supplyAsync(() -> { try { final PaymentIntent paymentIntent = getPaymentIntent(paymentIntentId); @@ -255,14 +255,14 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor } } - return new PaymentDetails(paymentIntent.getId(), + return Optional.of(new PaymentDetails(paymentIntent.getId(), paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(), getPaymentStatusForStatus(paymentIntent.getStatus()), Instant.ofEpochSecond(paymentIntent.getCreated()), - chargeFailure); + chargeFailure)); } catch (StripeException e) { if (e.getStatusCode() == 404) { - return null; + return Optional.empty(); } else { throw new CompletionException(e); } diff --git a/service/src/main/proto/org/signal/chat/one_time_donations.proto b/service/src/main/proto/org/signal/chat/one_time_donations.proto new file mode 100644 index 000000000..e7e456196 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/one_time_donations.proto @@ -0,0 +1,167 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.one_time_donations; + +import "org/signal/chat/require.proto"; +import "org/signal/chat/errors.proto"; +import "org/signal/chat/tag.proto"; +import "org/signal/chat/subscriptions.proto"; + +// Service for making one-time donation payments (boost and gift) +// +// Note that these are siblings of the RPCs in the Subscriptions service. One-time payments do +// not require the subscription management methods in that service, though the configuration at +// Subscriptions.GetConfiguration is shared between subscription and one-time payments. +service OneTimeDonations { + option (require.auth) = AUTH_ONLY_ANONYMOUS; + + // Create a Stripe payment intent and return a client secret that can be used to complete the payment. + // Once the payment is complete, the paymentIntentId can be used with CreateBoostReceiptCredentials + rpc CreateBoost(CreateBoostRequest) returns (CreateBoostResponse) {} + + // Create a PayPal one-time payment. + // Once the payment is complete, call ConfirmPayPalBoost with the payment ID and token + rpc CreatePayPalBoost(CreatePayPalBoostRequest) returns (CreatePayPalBoostResponse) {} + + // Confirm a PayPal one-time payment + rpc ConfirmPayPalBoost(ConfirmPayPalBoostRequest) returns (ConfirmPayPalBoostResponse) {} + + // Obtain a ZK receipt credential for a completed one-time donation payment. + // The receipt credential can then be used to redeem the one-time donation entitlement + // via Donations.RedeemReceipt + rpc CreateBoostReceiptCredentials(CreateBoostReceiptCredentialsRequest) returns (CreateBoostReceiptCredentialsResponse) {} +} + +// The amount is below the minimum for the currency. +message AmountBelowMinimumError { + // The minimum amount for the currency + string minimum = 1; +} + +// The SEPA Direct Debit amount exceeds the allowed maximum. +message AmountAboveSepaLimitError { + // The maximum amount for a SEPA transaction + string maximum = 1; +} + +message CreateBoostRequest { + // ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR") + string currency = 1 [(require.exactlySize) = 3]; + // The amount to pay in the [currency's minor unit](https://docs.stripe.com/currencies#minor-units) + uint64 amount = 2 [(require.range).min = 1]; + // The level for the boost payment + uint64 level = 3 [(require.range).min = 1]; + // The payment method + subscriptions.PaymentMethod payment_method = 4 [(require.specified) = true]; + // A donation permit retrieved from Donations.createDonationPermit + bytes donation_permit = 5 [(require.nonEmpty) = true]; +} + +message CreateBoostResponse { + oneof response { + // A client secret that can be used to complete a stripe PaymentIntent + string client_secret = 1; + // The amount is below the minimum for the currency + AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"]; + // The amount exceeds the maximum for SEPA Direct Debit + AmountAboveSepaLimitError amount_above_sepa_limit = 3 [(tag.reason) = "amount_above_sepa_limit"]; + // The requested currency is not supported for the given payment method + errors.FailedPrecondition unsupported_currency = 4 [(tag.reason) = "unsupported_currency"]; + // The requested level is not a valid one-time donation level + errors.FailedPrecondition unsupported_level = 5 [(tag.reason) = "unsupported_level"]; + // Donation permit was invalid or already spent + errors.FailedZkAuthentication permit_rejected = 6 [(tag.reason) = "permit_rejected"]; + } +} + +message CreatePayPalBoostRequest { + // ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR") + string currency = 1 [(require.exactlySize) = 3]; + // Amount in the currency's minor unit (e.g. cents for USD), must be >= 1 + uint64 amount = 2 [(require.range).min = 1]; + // Donation level. + uint64 level = 3 [(require.range).min = 1]; + // URL to redirect the user to after PayPal approval + string return_url = 4 [(require.nonEmpty) = true]; + // URL to redirect the user to if they cancel + string cancel_url = 5 [(require.nonEmpty) = true]; +} + +message CreatePayPalBoostResponse { + message CreatePayPalBoostResult { + string approval_url = 1; + string payment_id = 2; + } + oneof response { + CreatePayPalBoostResult result = 1; + // The amount is below the minimum for the currency + AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"]; + // The requested currency is not supported for PayPal + errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"]; + // The requested level is not a valid one-time donation level + errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"]; + } +} + +message ConfirmPayPalBoostRequest { + // ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR") + string currency = 1 [(require.exactlySize) = 3]; + // Amount in the currency's minor unit, must be >= 1 + uint64 amount = 2 [(require.range).min = 1]; + // Donation level. + uint64 level = 3 [(require.range).min = 1]; + // PayPal payer ID from the approval redirect + string payer_id = 4 [(require.nonEmpty) = true]; + // PayPal payment ID (PAYID-…) from CreatePayPalBoost + string payment_id = 5 [(require.nonEmpty) = true]; + // PayPal payment token (EC-…) from the approval redirect + string payment_token = 6 [(require.nonEmpty) = true]; +} + +message ConfirmPayPalBoostResponse { + message ConfirmPayPalBoostResult { + string payment_id = 1; + } + oneof response { + ConfirmPayPalBoostResult result = 1; + // The amount is below the minimum for the currency + AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"]; + // The requested currency is not supported for PayPal + errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"]; + // The requested level is not a valid one-time donation level + errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"]; + } +} + +message CreateBoostReceiptCredentialsRequest { + // a payment ID from the processor + string payment_intent_id = 1 [(require.nonEmpty) = true]; + // ZK blind-signature receipt credential request bytes + bytes receipt_credential_request = 2 [(require.nonEmpty) = true]; + // The processor that handled the payment + subscriptions.PaymentProvider processor = 3 [(require.specified) = true]; +} + +message CreateBoostReceiptCredentialsResponse { + message CreateBoostReceiptCredentialsResult { + bytes receipt_credential_response = 1; + } + oneof response { + CreateBoostReceiptCredentialsResult result = 1; + // Payment is still processing; client should retry + errors.FailedPrecondition payment_still_processing = 2 [(tag.reason) = "payment_still_processing"]; + // Payment failed + subscriptions.PaymentRequired payment_required = 3 [(tag.reason) = "payment_required"]; + // Payment intent not found + errors.NotFound payment_not_found = 4 [(tag.reason) = "payment_not_found"]; + // A receipt credential was already issued for this payment + errors.FailedPrecondition receipt_already_issued = 5 [(tag.reason) = "receipt_already_issued"]; + } +} diff --git a/service/src/main/proto/org/signal/chat/subscriptions.proto b/service/src/main/proto/org/signal/chat/subscriptions.proto index 888ce6f92..a0f426055 100644 --- a/service/src/main/proto/org/signal/chat/subscriptions.proto +++ b/service/src/main/proto/org/signal/chat/subscriptions.proto @@ -295,6 +295,10 @@ message ChargeFailure { optional string outcome_type = 6; } +message PaymentRequired { + optional ChargeFailure charge_failure = 1; +} + message SetSubscriptionLevelResponse { message SetSubscriptionLevelResult { uint64 level = 1; @@ -367,10 +371,6 @@ message GetReceiptCredentialsResponse { bytes receiptCredentialResponse = 1; } - message PaymentRequired { - optional ChargeFailure charge_failure = 1; - } - oneof response { GetReceiptCredentialsResult success = 1; errors.NotFound subscriber_not_found = 2 [(tag.reason) = "subscriber_not_found"]; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationControllerTest.java index dbc2626e6..c6970b924 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationControllerTest.java @@ -25,6 +25,7 @@ import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; @@ -239,12 +240,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest @ParameterizedTest @MethodSource void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) { - when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails( + when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails( "id", Collections.emptyMap(), PaymentStatus.FAILED, Instant.now(), - chargeFailure) + chargeFailure)) )); try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") .request() @@ -322,12 +323,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext( new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest(); - when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails( + when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails( "id", Collections.emptyMap(), PaymentStatus.SUCCEEDED, Instant.now(), - null))); + null)))); doThrow(WriteConflictException.class).when(ISSUED_RECEIPTS_MANAGER).recordIssuance(any(), any(), any(), any()); try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcServiceTest.java new file mode 100644 index 000000000..4e1d1f8ad --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationsGrpcServiceTest.java @@ -0,0 +1,336 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.net.InetAddresses; +import com.google.protobuf.ByteString; +import com.stripe.model.PaymentIntent; +import jakarta.annotation.Nullable; +import java.time.Instant; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest; +import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse; +import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest; +import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse; +import org.signal.chat.one_time_donations.CreateBoostRequest; +import org.signal.chat.one_time_donations.CreateBoostResponse; +import org.signal.chat.one_time_donations.CreatePayPalBoostRequest; +import org.signal.chat.one_time_donations.CreatePayPalBoostResponse; +import org.signal.chat.one_time_donations.OneTimeDonationsGrpc; +import org.signal.chat.subscriptions.PaymentMethod; +import org.signal.chat.subscriptions.PaymentProvider; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.donation.DonationPermit; +import org.signal.libsignal.zkgroup.donation.DonationPermitRequest; +import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext; +import org.signal.libsignal.zkgroup.donation.DonationPermitResponse; +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.DonationPermits; +import org.whispersystems.textsecuregcm.storage.DonationPermitsManager; +import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; +import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; +import org.whispersystems.textsecuregcm.storage.WriteConflictException; +import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; +import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; +import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails; +import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; +import org.whispersystems.textsecuregcm.subscriptions.StripeManager; +import org.whispersystems.textsecuregcm.tests.util.SubscriptionConfigTestHelper; +import org.whispersystems.textsecuregcm.util.TestClock; + +public class OneTimeDonationsGrpcServiceTest extends + SimpleBaseGrpcTest { + + private final TestClock clock = TestClock.pinned(Instant.now()); + + private final OneTimeDonationConfiguration oneTimeDonationConfiguration = + SubscriptionConfigTestHelper.getOneTimeConfig(); + + @Mock + private DonationPermits donationPermits; + + private static final ServerSecretParams DONATION_PERMITS_SECRET_PARAMS = ServerSecretParams.generate(); + private DonationPermitsManager donationPermitsManager; + + @Mock + private StripeManager stripeManager; + + @Mock + private BraintreeManager braintreeManager; + + @Mock + private PayPalDonationsTranslator payPalDonationsTranslator; + + @Mock + private OneTimeDonationsManager oneTimeDonationsManager; + + @Mock + private IssuedReceiptsManager issuedReceiptsManager; + + @Mock + private ServerZkReceiptOperations zkReceiptOperations; + + @Mock + private RateLimiters rateLimiters; + + @Mock + private RateLimiter rateLimiter; + + @Override + protected OneTimeDonationsGrpcService createServiceBeforeEachTest() { + getMockRequestAttributesInterceptor().setRequestAttributes( + new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-us")); + + donationPermitsManager = new DonationPermitsManager(donationPermits, DONATION_PERMITS_SECRET_PARAMS, clock); + + // spendIds are spend-once + final Set spent = new HashSet<>(); + when(donationPermits.spend(any(byte[].class), any(Instant.class))) + .thenAnswer(answer -> spent.add(new String(answer.getArgument(0, byte[].class)))); + + when(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)).thenReturn(rateLimiter); + + return new OneTimeDonationsGrpcService( + oneTimeDonationConfiguration, + stripeManager, + braintreeManager, + payPalDonationsTranslator, + oneTimeDonationsManager, + issuedReceiptsManager, + zkReceiptOperations, + clock, + rateLimiters, + donationPermitsManager); + } + + @Test + void createBoost() { + final PaymentIntent paymentIntent = mock(PaymentIntent.class); + when(paymentIntent.getClientSecret()).thenReturn("test-client-secret"); + when(stripeManager.getSupportedCurrenciesForPaymentMethod( + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD)) + .thenReturn(Set.of("usd")); + when(stripeManager.createPaymentIntent(any(), anyLong(), anyLong(), any())) + .thenReturn(CompletableFuture.completedFuture(paymentIntent)); + + final CreateBoostResponse response = unauthenticatedServiceStub().createBoost( + CreateBoostRequest.newBuilder() + .setCurrency("usd") + .setAmount(500) + .setLevel(1) + .setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD) + .setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize())) + .build()); + + assertEquals(CreateBoostResponse.ResponseCase.CLIENT_SECRET, response.getResponseCase()); + assertEquals("test-client-secret", response.getClientSecret()); + } + + @Test + void createBoostPermitAlreadySpent() { + when(donationPermits.spend(any(byte[].class), any(Instant.class))).thenReturn(false); + + final CreateBoostResponse response = unauthenticatedServiceStub().createBoost( + CreateBoostRequest.newBuilder() + .setCurrency("usd") + .setAmount(500) + .setLevel(1) + .setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD) + .setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize())) + .build()); + + assertEquals(CreateBoostResponse.ResponseCase.PERMIT_REJECTED, response.getResponseCase()); + } + + @ParameterizedTest + @MethodSource + void createBoostValidationErrors( + final String currency, + final long amount, + final PaymentMethod paymentMethod, + final CreateBoostResponse.ResponseCase expectedResponseCase) { + when(stripeManager.getSupportedCurrenciesForPaymentMethod( + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD)) + .thenReturn(Set.of("usd")); + when(stripeManager.getSupportedCurrenciesForPaymentMethod( + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT)) + .thenReturn(Set.of("eur")); + + final CreateBoostResponse response = unauthenticatedServiceStub().createBoost( + CreateBoostRequest.newBuilder() + .setCurrency(currency) + .setAmount(amount) + .setLevel(1) + .setPaymentMethod(paymentMethod) + .setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize())) + .build()); + + assertEquals(expectedResponseCase, response.getResponseCase()); + } + + static Stream createBoostValidationErrors() { + return Stream.of( + Arguments.of("usd", 249L, PaymentMethod.PAYMENT_METHOD_CARD, + CreateBoostResponse.ResponseCase.AMOUNT_BELOW_MINIMUM), + Arguments.of("eur", 1000001L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT, + CreateBoostResponse.ResponseCase.AMOUNT_ABOVE_SEPA_LIMIT), + // USD is not supported for SEPA_DEBIT + Arguments.of("usd", 3000L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT, + CreateBoostResponse.ResponseCase.UNSUPPORTED_CURRENCY) + ); + } + + @Test + void createPayPalBoost() { + final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails = + mock(BraintreeManager.PayPalOneTimePaymentApprovalDetails.class); + when(approvalDetails.approvalUrl()).thenReturn("test-approval-url"); + when(approvalDetails.paymentId()).thenReturn("test-id"); + when(braintreeManager.getSupportedCurrenciesForPaymentMethod( + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL)) + .thenReturn(Set.of("usd")); + when(payPalDonationsTranslator.translate(any(), any())) + .thenReturn("Donation to Signal Technology Foundation"); + when(braintreeManager.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(), + anyString())) + .thenReturn(CompletableFuture.completedFuture(approvalDetails)); + + final CreatePayPalBoostResponse response = unauthenticatedServiceStub().createPayPalBoost( + CreatePayPalBoostRequest.newBuilder() + .setCurrency("usd") + .setAmount(300) + .setLevel(1) + .setReturnUrl("returnUrl") + .setCancelUrl("cancelUrl") + .build()); + + assertEquals(CreatePayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase()); + assertEquals("test-approval-url", response.getResult().getApprovalUrl()); + assertEquals("test-id", response.getResult().getPaymentId()); + } + + @Test + void confirmPayPalBoost() { + when(braintreeManager.getSupportedCurrenciesForPaymentMethod( + org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL)) + .thenReturn(Set.of("usd")); + when(braintreeManager.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(), + anyLong(), any())) + .thenReturn(CompletableFuture.completedFuture( + new BraintreeManager.PayPalChargeSuccessDetails("test-id"))); + + final ConfirmPayPalBoostResponse response = unauthenticatedServiceStub().confirmPayPalBoost( + ConfirmPayPalBoostRequest.newBuilder() + .setCurrency("usd") + .setAmount(300) + .setLevel(1) + .setPayerId("test-payer-id") + .setPaymentId("test-payment-id") + .setPaymentToken("test-payment-token") + .build()); + + assertEquals(ConfirmPayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase()); + assertEquals("test-id", response.getResult().getPaymentId()); + } + + @ParameterizedTest + @MethodSource + void createBoostReceiptCredentialsPaymentRequired( + @Nullable final ChargeFailure chargeFailure, + final boolean expectChargeFailure) { + when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture( + Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.FAILED, + clock.instant(), chargeFailure)))); + + final CreateBoostReceiptCredentialsResponse response = + unauthenticatedServiceStub().createBoostReceiptCredentials( + CreateBoostReceiptCredentialsRequest.newBuilder() + .setPaymentIntentId("test-payment-intent-id") + .setReceiptCredentialRequest(ByteString.copyFromUtf8("abcd")) + .setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE) + .build()); + + assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.PAYMENT_REQUIRED, + response.getResponseCase()); + if (expectChargeFailure) { + assertEquals("generic_decline", response.getPaymentRequired().getChargeFailure().getCode()); + } else { + assertFalse(response.getPaymentRequired().hasChargeFailure()); + } + } + + static Stream createBoostReceiptCredentialsPaymentRequired() { + return Stream.of( + Arguments.of(new ChargeFailure("generic_decline", "some failure message", null, null, null), true), + Arguments.of(null, false) + ); + } + + @Test + void createBoostReceiptCredentialsAlreadyRedeemed() throws Exception { + final ReceiptCredentialRequest receiptCredentialRequest = new ClientZkReceiptOperations( + ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext( + new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest(); + + when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture( + Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.SUCCEEDED, + clock.instant(), null)))); + doThrow(WriteConflictException.class).when(issuedReceiptsManager) + .recordIssuance(any(), any(), any(), any()); + + final CreateBoostReceiptCredentialsResponse response = + unauthenticatedServiceStub().createBoostReceiptCredentials( + CreateBoostReceiptCredentialsRequest.newBuilder() + .setPaymentIntentId("test-payment-intent-id") + .setReceiptCredentialRequest(ByteString.copyFrom(receiptCredentialRequest.serialize())) + .setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE) + .build()); + + assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.RECEIPT_ALREADY_ISSUED, + response.getResponseCase()); + } + + private DonationPermit getDonationPermit() { + final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(1); + final DonationPermitRequest permitRequest = context.request(); + final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest); + try { + final List permits = context.receive( + permitResponse, DONATION_PERMITS_SECRET_PARAMS.getPublicParams(), clock.instant()); + return permits.getFirst(); + } catch (final VerificationFailedException e) { + throw new AssertionError("The permit was correctly requested and issued in this method", e); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcServiceTest.java index 4f6c6bc9d..4fe149069 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SubscriptionsGrpcServiceTest.java @@ -97,6 +97,7 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumen import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidIdempotencyKeyException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPrice; @@ -594,7 +595,7 @@ public class SubscriptionsGrpcServiceTest extends final byte[] responseBytes = TestRandomUtil.nextBytes(16); when(receiptCredentialResponse.serialize()).thenReturn(responseBytes); when(subscriptionManager.createReceiptCredentials(any(), any(), any())) - .thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, null, PaymentProvider.STRIPE)); + .thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, new SubscriptionPaymentProcessor.ReceiptItem("test-item-id", null, 5), PaymentProvider.STRIPE)); final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials( GetReceiptCredentialsRequest.newBuilder() .setSubscriberId(SUBSCRIBER_ID)