Group one-time donation methods together

This commit is contained in:
ravi-signal 2024-08-15 13:25:09 -05:00 committed by GitHub
parent b5f9564e13
commit a8eaf2d0ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 507 additions and 372 deletions

View File

@ -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<WhisperServerConfiguration
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager,
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
}
for (Object controller : commonControllers) {

View File

@ -0,0 +1,410 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
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.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
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;
import org.whispersystems.websocket.auth.ReadOnly;
/**
* Endpoints for making one-time donation payments (boost and gift)
* <p>
* 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<Response> createBoostPaymentIntent(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> createPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> confirmPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> createBoostReceiptCredentials(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<PaymentDetails> 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;
}
}
}

View File

@ -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<Locale> 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.
*

View File

@ -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<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build();
});
}
@ -609,7 +594,7 @@ public class SubscriptionController {
public CompletableFuture<Response> getBankMandate(final @Context ContainerRequestContext containerRequestContext,
final @PathParam("bankTransferType") BankTransferType bankTransferType) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
List<Locale> 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<Long, Level> 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<Response> createBoostPaymentIntent(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> createPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> confirmPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Response> createBoostReceiptCredentials(
@ReadOnly @Auth Optional<AuthenticatedDevice> 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<Locale> 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 {

View File

@ -133,7 +133,6 @@ public class BraintreeManager implements SubscriptionProcessorManager {
return paymentMethod == PaymentMethod.PAYPAL;
}
@Override
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
return CompletableFuture.supplyAsync(() -> {
try {

View File

@ -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<String, String> customMetadata,
PaymentStatus status,
Instant created,
@Nullable ChargeFailure chargeFailure) {}

View File

@ -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,
}

View File

@ -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<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
@ -58,21 +55,6 @@ public interface SubscriptionProcessorManager {
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscription);
record PaymentDetails(String id,
Map<String, String> 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.

View File

@ -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<BasicCredentials> 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<Locale> 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();
}
}
}

View File

@ -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("{}");
}