diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 995088cd4..dd0f78d1f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -111,6 +111,7 @@ import org.whispersystems.textsecuregcm.controllers.ArtController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4; +import org.whispersystems.textsecuregcm.controllers.OneTimeDonationController; import org.whispersystems.textsecuregcm.controllers.CallLinkController; import org.whispersystems.textsecuregcm.controllers.CallRoutingController; import org.whispersystems.textsecuregcm.controllers.CertificateController; @@ -1119,8 +1120,10 @@ public class WhisperServerService extends Application + * Note that these siblings of the endpoints at /v1/subscription on {@link SubscriptionController}. One-time payments do + * not require the subscription management methods on that controller, though the configuration at + * /v1/subscription/configuration is shared between subscription and one-time payments. + */ +@Path("/v1/subscription/boost") +@io.swagger.v3.oas.annotations.tags.Tag(name = "OneTimeDonations") +public class OneTimeDonationController { + + private static final Logger logger = LoggerFactory.getLogger(SubscriptionController.class); + + private static final String AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME = + MetricsUtil.name(SubscriptionController.class, "authenticatedBoostOperation"); + private static final String OPERATION_TAG_NAME = "operation"; + private static final String EURO_CURRENCY_CODE = "EUR"; + + private final Clock clock; + private final OneTimeDonationConfiguration oneTimeDonationConfiguration; + private final StripeManager stripeManager; + private final BraintreeManager braintreeManager; + private final ServerZkReceiptOperations zkReceiptOperations; + private final IssuedReceiptsManager issuedReceiptsManager; + private final OneTimeDonationsManager oneTimeDonationsManager; + + public OneTimeDonationController( + @Nonnull Clock clock, + @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration, + @Nonnull StripeManager stripeManager, + @Nonnull BraintreeManager braintreeManager, + @Nonnull ServerZkReceiptOperations zkReceiptOperations, + @Nonnull IssuedReceiptsManager issuedReceiptsManager, + @Nonnull OneTimeDonationsManager oneTimeDonationsManager) { + this.clock = Objects.requireNonNull(clock); + this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); + this.stripeManager = Objects.requireNonNull(stripeManager); + this.braintreeManager = Objects.requireNonNull(braintreeManager); + this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations); + this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); + this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager); + } + + public static class CreateBoostRequest { + + @NotEmpty + @ExactlySize(3) + public String currency; + @Min(1) + public long amount; + public Long level; + public PaymentMethod paymentMethod = PaymentMethod.CARD; + } + + public record CreateBoostResponse(String clientSecret) {} + + + /** + * Creates a Stripe PaymentIntent with the requested amount and currency + */ + @POST + @Path("/create") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture createBoostPaymentIntent( + @ReadOnly @Auth Optional authenticatedAccount, + @NotNull @Valid CreateBoostRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { + + if (authenticatedAccount.isPresent()) { + Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of(OPERATION_TAG_NAME, "boost/create"))).increment(); + } + + return CompletableFuture.runAsync(() -> { + if (request.level == null) { + request.level = oneTimeDonationConfiguration.boost().level(); + } + BigDecimal amount = BigDecimal.valueOf(request.amount); + if (request.level == oneTimeDonationConfiguration.gift().level()) { + BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies() + .get(request.currency.toLowerCase(Locale.ROOT)).gift(); + if (amountConfigured == null || + SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) + .compareTo(amount) != 0) { + throw new WebApplicationException( + Response.status(Response.Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); + } + } + validateRequestCurrencyAmount(request, amount, stripeManager); + }) + .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level, + getClientPlatform(userAgent))) + .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); + } + + /** + * Validates that 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(CreateBoostRequest request, BigDecimal amount, + SubscriptionProcessorManager manager) { + 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()); + } + + BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() + .get(request.currency.toLowerCase(Locale.ROOT)).minimum(); + 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 { + + @NotEmpty + public String returnUrl; + @NotEmpty + public String cancelUrl; + + public CreatePayPalBoostRequest() { + super.paymentMethod = PaymentMethod.PAYPAL; + } + } + + record CreatePayPalBoostResponse(String approvalUrl, String paymentId) {} + + @POST + @Path("/paypal/create") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture createPayPalBoost( + @ReadOnly @Auth Optional authenticatedAccount, + @NotNull @Valid CreatePayPalBoostRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, + @Context ContainerRequestContext containerRequestContext) { + + if (authenticatedAccount.isPresent()) { + Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of(OPERATION_TAG_NAME, "boost/paypal/create"))).increment(); + } + + return CompletableFuture.runAsync(() -> { + if (request.level == null) { + request.level = oneTimeDonationConfiguration.boost().level(); + } + + validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager); + }) + .thenCompose(unused -> { + final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream() + .filter(l -> !"*".equals(l.getLanguage())) + .findFirst() + .orElse(Locale.US); + + return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount, + locale.toLanguageTag(), + request.returnUrl, request.cancelUrl); + }) + .thenApply(approvalDetails -> Response.ok( + new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build()); + } + + public static class ConfirmPayPalBoostRequest extends CreateBoostRequest { + + @NotEmpty + public String payerId; + @NotEmpty + public String paymentId; // PAYID-… + @NotEmpty + public String paymentToken; // EC-… + } + + record ConfirmPayPalBoostResponse(String paymentId) {} + + @POST + @Path("/paypal/confirm") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture confirmPayPalBoost( + @ReadOnly @Auth Optional authenticatedAccount, + @NotNull @Valid ConfirmPayPalBoostRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { + + if (authenticatedAccount.isPresent()) { + Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of(OPERATION_TAG_NAME, "boost/paypal/confirm"))).increment(); + } + + return CompletableFuture.runAsync(() -> { + if (request.level == null) { + request.level = oneTimeDonationConfiguration.boost().level(); + } + }) + .thenCompose(unused -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId, + request.paymentToken, request.currency, request.amount, request.level, getClientPlatform(userAgent))) + .thenCompose( + chargeSuccessDetails -> oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now())) + .thenApply(paymentId -> Response.ok( + new ConfirmPayPalBoostResponse(paymentId)).build()); + } + + public static class CreateBoostReceiptCredentialsRequest { + + /** + * a payment ID from {@link #processor} + */ + @NotNull + public String paymentIntentId; + @NotNull + public byte[] receiptCredentialRequest; + + @NotNull + public SubscriptionProcessor processor = SubscriptionProcessor.STRIPE; + } + + public record CreateBoostReceiptCredentialsSuccessResponse(byte[] receiptCredentialResponse) { + } + + public record CreateBoostReceiptCredentialsErrorResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) ChargeFailure chargeFailure) {} + + @POST + @Path("/receipt_credentials") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture createBoostReceiptCredentials( + @ReadOnly @Auth Optional authenticatedAccount, + @NotNull @Valid final CreateBoostReceiptCredentialsRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { + + if (authenticatedAccount.isPresent()) { + Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of(OPERATION_TAG_NAME, "boost/receipt_credentials"))).increment(); + } + + final CompletableFuture paymentDetailsFut = switch (request.processor) { + case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId); + case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId); + }; + + return paymentDetailsFut.thenCompose(paymentDetails -> { + if (paymentDetails == null) { + throw new WebApplicationException(Response.Status.NOT_FOUND); + } + switch (paymentDetails.status()) { + case PROCESSING -> throw new WebApplicationException(Response.Status.NO_CONTENT); + case SUCCEEDED -> { + } + default -> throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED) + .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build()); + } + + 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); + throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR); + } + ReceiptCredentialRequest receiptCredentialRequest; + try { + receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest); + } catch (InvalidInputException e) { + throw new BadRequestException("invalid receipt credential request", e); + } + final long finalLevel = level; + return issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor, + receiptCredentialRequest, clock.instant()) + .thenCompose(unused -> oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created())) + .thenApply(paidAt -> { + Instant expiration = paidAt + .plus(levelExpiration) + .truncatedTo(ChronoUnit.DAYS) + .plus(1, ChronoUnit.DAYS); + ReceiptCredentialResponse receiptCredentialResponse; + try { + receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( + receiptCredentialRequest, expiration.getEpochSecond(), finalLevel); + } catch (VerificationFailedException e) { + throw new BadRequestException("receipt credential request failed verification", e); + } + Metrics.counter(SubscriptionController.RECEIPT_ISSUED_COUNTER_NAME, + Tags.of( + Tag.of(SubscriptionController.PROCESSOR_TAG_NAME, request.processor.toString()), + Tag.of(SubscriptionController.TYPE_TAG_NAME, "boost"), + UserAgentTagUtil.getPlatformTag(userAgent))) + .increment(); + return Response.ok( + new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize())) + .build(); + }); + }); + } + + @Nullable + private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) { + try { + return UserAgentUtil.parseUserAgentString(userAgentString).getPlatform(); + } catch (final UnrecognizedUserAgentException e) { + return null; + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index 8da674885..c4fb51ce3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -446,7 +446,7 @@ public class ProfileController { account.isUnrestrictedUnidentifiedAccess(), UserCapabilities.createForAccount(account), profileBadgeConverter.convert( - getAcceptableLanguagesForRequest(containerRequestContext), + HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext), account.getBadges(), isSelf), new AciServiceIdentifier(account.getUuid())); @@ -461,21 +461,6 @@ public class ProfileController { new PniServiceIdentifier(account.getPhoneNumberIdentifier())); } - private List getAcceptableLanguagesForRequest(final ContainerRequestContext containerRequestContext) { - try { - return containerRequestContext.getAcceptableLanguages(); - } catch (final ProcessingException e) { - final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT); - Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); - logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}", - containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE), - userAgent, - e); - - return List.of(); - } - } - /** * Verifies that the requester has permission to view the profile of the account identified by the given ACI. * 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 07c071f3d..3353b1946 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -22,7 +22,6 @@ import java.math.BigDecimal; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -43,7 +42,6 @@ import javax.annotation.Nullable; 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; @@ -61,10 +59,8 @@ import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; -import javax.ws.rs.ProcessingException; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -90,7 +86,6 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; -import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; @@ -100,10 +95,9 @@ import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; 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; +import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; @@ -123,20 +117,13 @@ public class SubscriptionController { private final BraintreeManager braintreeManager; private final ServerZkReceiptOperations zkReceiptOperations; private final IssuedReceiptsManager issuedReceiptsManager; - private final OneTimeDonationsManager oneTimeDonationsManager; private final BadgeTranslator badgeTranslator; private final LevelTranslator levelTranslator; private final BankMandateTranslator bankMandateTranslator; - private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, - "invalidAcceptLanguage"); - private static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued"); - private static final String AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME = - MetricsUtil.name(SubscriptionController.class, "authenticatedBoostOperation"); - public static final String OPERATION_TAG_NAME = "operation"; - private static final String PROCESSOR_TAG_NAME = "processor"; - private static final String TYPE_TAG_NAME = "type"; + static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.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"; - private static final String EURO_CURRENCY_CODE = "EUR"; public SubscriptionController( @Nonnull Clock clock, @@ -147,7 +134,6 @@ public class SubscriptionController { @Nonnull BraintreeManager braintreeManager, @Nonnull ServerZkReceiptOperations zkReceiptOperations, @Nonnull IssuedReceiptsManager issuedReceiptsManager, - @Nonnull OneTimeDonationsManager oneTimeDonationsManager, @Nonnull BadgeTranslator badgeTranslator, @Nonnull LevelTranslator levelTranslator, @Nonnull BankMandateTranslator bankMandateTranslator) { @@ -159,7 +145,6 @@ public class SubscriptionController { this.braintreeManager = Objects.requireNonNull(braintreeManager); this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations); this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); - this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager); this.badgeTranslator = Objects.requireNonNull(badgeTranslator); this.levelTranslator = Objects.requireNonNull(levelTranslator); this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator); @@ -390,7 +375,7 @@ public class SubscriptionController { return updatedRecordFuture.thenCompose( updatedRecord -> { - final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream() + final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream() .filter(l -> !"*".equals(l.getLanguage())) .findFirst() .orElse(Locale.US); @@ -598,7 +583,7 @@ public class SubscriptionController { @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetSubscriptionConfigurationResponse.class))) public CompletableFuture getConfiguration(@Context ContainerRequestContext containerRequestContext) { return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); + List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build(); }); } @@ -609,7 +594,7 @@ public class SubscriptionController { public CompletableFuture getBankMandate(final @Context ContainerRequestContext containerRequestContext, final @PathParam("bankTransferType") BankTransferType bankTransferType) { return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); + List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); return Response.ok(new GetBankMandateResponse( bankMandateTranslator.translate(acceptableLanguages, bankTransferType))).build(); }); @@ -617,298 +602,6 @@ public class SubscriptionController { public record GetBankMandateResponse(String mandate) {} - public record GetBoostBadgesResponse(Map levels) { - public record Level(PurchasableBadge badge) { - } - } - - public static class CreateBoostRequest { - - @NotEmpty - @ExactlySize(3) - public String currency; - @Min(1) - public long amount; - public Long level; - public PaymentMethod paymentMethod = PaymentMethod.CARD; - } - - public static class CreatePayPalBoostRequest extends CreateBoostRequest { - - @NotEmpty - public String returnUrl; - @NotEmpty - public String cancelUrl; - - public CreatePayPalBoostRequest() { - super.paymentMethod = PaymentMethod.PAYPAL; - } - } - - record CreatePayPalBoostResponse(String approvalUrl, String paymentId) { - - } - - public record CreateBoostResponse(String clientSecret) { - } - - /** - * Creates a Stripe PaymentIntent with the requested amount and currency - */ - @POST - @Path("/boost/create") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createBoostPaymentIntent( - @ReadOnly @Auth Optional authenticatedAccount, - @NotNull @Valid CreateBoostRequest request, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { - - if (authenticatedAccount.isPresent()) { - Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(OPERATION_TAG_NAME, "boost/create"))).increment(); - } - - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - BigDecimal amount = BigDecimal.valueOf(request.amount); - if (request.level == oneTimeDonationConfiguration.gift().level()) { - BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies() - .get(request.currency.toLowerCase(Locale.ROOT)).gift(); - if (amountConfigured == null || - SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) - .compareTo(amount) != 0) { - throw new WebApplicationException( - Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); - } - } - validateRequestCurrencyAmount(request, amount, stripeManager); - }) - .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level, getClientPlatform(userAgent))) - .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); - } - - /** - * Validates that 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(CreateBoostRequest request, BigDecimal amount, - SubscriptionProcessorManager manager) { - if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod).contains(request.currency.toLowerCase(Locale.ROOT))) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(Map.of("error", "unsupported_currency")).build()); - } - - BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() - .get(request.currency.toLowerCase(Locale.ROOT)).minimum(); - BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( - request.currency, - minCurrencyAmountMajorUnits); - if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) { - throw new BadRequestException(Response.status(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(Status.BAD_REQUEST) - .entity(Map.of( - "error", "amount_above_sepa_limit", - "maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build()); - } - } - - @POST - @Path("/boost/paypal/create") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createPayPalBoost( - @ReadOnly @Auth Optional authenticatedAccount, - @NotNull @Valid CreatePayPalBoostRequest request, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, - @Context ContainerRequestContext containerRequestContext) { - - if (authenticatedAccount.isPresent()) { - Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(OPERATION_TAG_NAME, "boost/paypal/create"))).increment(); - } - - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - - validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager); - }) - .thenCompose(unused -> { - final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream() - .filter(l -> !"*".equals(l.getLanguage())) - .findFirst() - .orElse(Locale.US); - - return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount, - locale.toLanguageTag(), - request.returnUrl, request.cancelUrl); - }) - .thenApply(approvalDetails -> Response.ok( - new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build()); - } - - public static class ConfirmPayPalBoostRequest extends CreateBoostRequest { - - @NotEmpty - public String payerId; - @NotEmpty - public String paymentId; // PAYID-… - @NotEmpty - public String paymentToken; // EC-… - } - - record ConfirmPayPalBoostResponse(String paymentId) { - - } - - @POST - @Path("/boost/paypal/confirm") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture confirmPayPalBoost( - @ReadOnly @Auth Optional authenticatedAccount, - @NotNull @Valid ConfirmPayPalBoostRequest request, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { - - if (authenticatedAccount.isPresent()) { - Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(OPERATION_TAG_NAME, "boost/paypal/confirm"))).increment(); - } - - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - }) - .thenCompose(unused -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId, - request.paymentToken, request.currency, request.amount, request.level, getClientPlatform(userAgent))) - .thenCompose(chargeSuccessDetails -> oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now())) - .thenApply(paymentId -> Response.ok( - new ConfirmPayPalBoostResponse(paymentId)).build()); - } - - public static class CreateBoostReceiptCredentialsRequest { - - /** - * a payment ID from {@link #processor} - */ - @NotNull - public String paymentIntentId; - @NotNull - public byte[] receiptCredentialRequest; - - @NotNull - public SubscriptionProcessor processor = SubscriptionProcessor.STRIPE; - } - - public record CreateBoostReceiptCredentialsSuccessResponse(byte[] receiptCredentialResponse) { - } - - public record CreateBoostReceiptCredentialsErrorResponse(@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {} - - @POST - @Path("/boost/receipt_credentials") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createBoostReceiptCredentials( - @ReadOnly @Auth Optional authenticatedAccount, - @NotNull @Valid final CreateBoostReceiptCredentialsRequest request, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { - - if (authenticatedAccount.isPresent()) { - Metrics.counter(AUTHENTICATED_BOOST_OPERATION_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(OPERATION_TAG_NAME, "boost/receipt_credentials"))).increment(); - } - - final SubscriptionProcessorManager manager = getManagerForProcessor(request.processor); - - return manager.getPaymentDetails(request.paymentIntentId) - .thenCompose(paymentDetails -> { - if (paymentDetails == null) { - throw new WebApplicationException(Status.NOT_FOUND); - } - switch (paymentDetails.status()) { - case PROCESSING -> throw new WebApplicationException(Status.NO_CONTENT); - case SUCCEEDED -> { - } - default -> throw new WebApplicationException(Response.status(Status.PAYMENT_REQUIRED) - .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build()); - } - - 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(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); - throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); - } - ReceiptCredentialRequest receiptCredentialRequest; - try { - receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest); - } catch (InvalidInputException e) { - throw new BadRequestException("invalid receipt credential request", e); - } - final long finalLevel = level; - return issuedReceiptsManager.recordIssuance(paymentDetails.id(), manager.getProcessor(), - receiptCredentialRequest, clock.instant()) - .thenCompose(unused -> oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created())) - .thenApply(paidAt -> { - Instant expiration = paidAt - .plus(levelExpiration) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS); - ReceiptCredentialResponse receiptCredentialResponse; - try { - receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( - receiptCredentialRequest, expiration.getEpochSecond(), finalLevel); - } catch (VerificationFailedException e) { - throw new BadRequestException("receipt credential request failed verification", e); - } - Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME, - Tags.of( - Tag.of(PROCESSOR_TAG_NAME, manager.getProcessor().toString()), - Tag.of(TYPE_TAG_NAME, "boost"), - UserAgentTagUtil.getPlatformTag(userAgent))) - .increment(); - return Response.ok(new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize())) - .build(); - }); - }); - } - public record GetSubscriptionInformationResponse( SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription, @JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) { @@ -1086,21 +779,6 @@ public class SubscriptionController { } } - private List getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { - try { - return containerRequestContext.getAcceptableLanguages(); - } catch (final ProcessingException e) { - final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT); - Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); - logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}", - containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE), - userAgent, - e); - - return List.of(); - } - } - @Nullable private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) { try { 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 63960a028..bd40b48fc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -133,7 +133,6 @@ public class BraintreeManager implements SubscriptionProcessorManager { return paymentMethod == PaymentMethod.PAYPAL; } - @Override public CompletableFuture getPaymentDetails(final String paymentId) { return CompletableFuture.supplyAsync(() -> { try { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentDetails.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentDetails.java new file mode 100644 index 000000000..de4748c7c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentDetails.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +import java.time.Instant; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Payment details for a one-time payment specified by id + * + * @param id The id of the payment in the payment processor + * @param customMetadata Any custom metadata attached to the payment + * @param status The status of the payment in the payment processor + * @param created When the payment was created + * @param chargeFailure If present, additional information about why the payment failed. Will not be set if the status + * is not {@link PaymentStatus#SUCCEEDED} + */ +public record PaymentDetails(String id, + Map customMetadata, + PaymentStatus status, + Instant created, + @Nullable ChargeFailure chargeFailure) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentStatus.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentStatus.java new file mode 100644 index 000000000..4e3bccdb5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentStatus.java @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public enum PaymentStatus { + SUCCEEDED, + PROCESSING, + FAILED, + UNKNOWN, +} 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 aafd4794e..af6992cc3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -7,7 +7,6 @@ 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; @@ -22,8 +21,6 @@ public interface SubscriptionProcessorManager { Set getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod); - CompletableFuture getPaymentDetails(String paymentId); - CompletableFuture createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform); CompletableFuture createPaymentMethodSetupToken(String customerId); @@ -58,21 +55,6 @@ public interface SubscriptionProcessorManager { CompletableFuture getSubscriptionInformation(Object subscription); - record PaymentDetails(String id, - Map customMetadata, - PaymentStatus status, - Instant created, - @Nullable ChargeFailure chargeFailure) { - - } - - enum PaymentStatus { - SUCCEEDED, - PROCESSING, - FAILED, - UNKNOWN, - } - enum SubscriptionStatus { /** * The subscription is in good standing and the most recent payment was successful. diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java index 72a905360..b2970b35c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java @@ -7,15 +7,29 @@ package org.whispersystems.textsecuregcm.util; import static java.util.Objects.requireNonNull; +import com.google.common.net.HttpHeaders; import io.dropwizard.auth.basic.BasicCredentials; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.List; +import java.util.Locale; import java.util.Optional; import javax.annotation.Nonnull; +import javax.ws.rs.ProcessingException; +import javax.ws.rs.container.ContainerRequestContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.metrics.MetricsUtil; +import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; public final class HeaderUtils { + private static final Logger logger = LoggerFactory.getLogger(HeaderUtils.class); + public static final String X_SIGNAL_AGENT = "X-Signal-Agent"; public static final String X_SIGNAL_KEY = "X-Signal-Key"; @@ -26,6 +40,9 @@ public final class HeaderUtils { public static final String GROUP_SEND_TOKEN = "Group-Send-Token"; + private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(HeaderUtils.class, + "invalidAcceptLanguage"); + private HeaderUtils() { // utility class } @@ -46,9 +63,8 @@ public final class HeaderUtils { } /** - * Parses a Base64-encoded value of the `Authorization` header - * in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`. - * Note: parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}. + * Parses a Base64-encoded value of the `Authorization` header in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`. Note: + * parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}. */ public static Optional basicCredentialsFromAuthHeader(final String authHeader) { final int space = authHeader.indexOf(' '); @@ -78,4 +94,24 @@ public final class HeaderUtils { final String password = decoded.substring(i + 1); return Optional.of(new BasicCredentials(username, password)); } + + public static List getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { + try { + return containerRequestContext.getAcceptableLanguages(); + } catch (final ProcessingException e) { + final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT); + Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of("path", containerRequestContext.getUriInfo().getPath()))) + .increment(); + logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}", + containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE), + userAgent, + e); + + return List.of(); + } + } + + } 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 f59d205ba..9cb0d5fd0 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -82,7 +82,9 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails; import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; +import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails; import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; +import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; @@ -113,7 +115,9 @@ class SubscriptionControllerTest { private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController( CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, - ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, BANK_MANDATE_TRANSLATOR); + ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, BANK_MANDATE_TRANSLATOR); + private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK, ONETIME_CONFIG, STRIPE_MANAGER, + BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER); private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProvider(AuthHelper.getAuthFilter()) @@ -123,6 +127,7 @@ class SubscriptionControllerTest { .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource(SUBSCRIPTION_CONTROLLER) + .addResource(ONE_TIME_CONTROLLER) .build(); @BeforeEach @@ -280,10 +285,10 @@ class SubscriptionControllerTest { @ParameterizedTest @MethodSource void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) { - when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new SubscriptionProcessorManager.PaymentDetails( + when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails( "id", Collections.emptyMap(), - SubscriptionProcessorManager.PaymentStatus.FAILED, + PaymentStatus.FAILED, Instant.now(), chargeFailure) )); @@ -299,7 +304,7 @@ class SubscriptionControllerTest { assertThat(response.getStatus()).isEqualTo(402); if (expectChargeFailure) { - assertThat(response.readEntity(SubscriptionController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure); + assertThat(response.readEntity(OneTimeDonationController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure); } else { assertThat(response.readEntity(String.class)).isEqualTo("{}"); }