Group one-time donation methods together
This commit is contained in:
parent
b5f9564e13
commit
a8eaf2d0ad
|
@ -111,6 +111,7 @@ import org.whispersystems.textsecuregcm.controllers.ArtController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
|
||||||
|
import org.whispersystems.textsecuregcm.controllers.OneTimeDonationController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
|
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CallRoutingController;
|
import org.whispersystems.textsecuregcm.controllers.CallRoutingController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
||||||
|
@ -1119,8 +1120,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
);
|
);
|
||||||
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
||||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||||
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
|
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager,
|
||||||
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
|
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
|
||||||
|
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
|
||||||
|
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Object controller : commonControllers) {
|
for (Object controller : commonControllers) {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -446,7 +446,7 @@ public class ProfileController {
|
||||||
account.isUnrestrictedUnidentifiedAccess(),
|
account.isUnrestrictedUnidentifiedAccess(),
|
||||||
UserCapabilities.createForAccount(account),
|
UserCapabilities.createForAccount(account),
|
||||||
profileBadgeConverter.convert(
|
profileBadgeConverter.convert(
|
||||||
getAcceptableLanguagesForRequest(containerRequestContext),
|
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext),
|
||||||
account.getBadges(),
|
account.getBadges(),
|
||||||
isSelf),
|
isSelf),
|
||||||
new AciServiceIdentifier(account.getUuid()));
|
new AciServiceIdentifier(account.getUuid()));
|
||||||
|
@ -461,21 +461,6 @@ public class ProfileController {
|
||||||
new PniServiceIdentifier(account.getPhoneNumberIdentifier()));
|
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.
|
* Verifies that the requester has permission to view the profile of the account identified by the given ACI.
|
||||||
*
|
*
|
||||||
|
|
|
@ -22,7 +22,6 @@ import java.math.BigDecimal;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
@ -43,7 +42,6 @@ import javax.annotation.Nullable;
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.Min;
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
@ -61,10 +59,8 @@ import javax.ws.rs.POST;
|
||||||
import javax.ws.rs.PUT;
|
import javax.ws.rs.PUT;
|
||||||
import javax.ws.rs.Path;
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.PathParam;
|
import javax.ws.rs.PathParam;
|
||||||
import javax.ws.rs.ProcessingException;
|
|
||||||
import javax.ws.rs.Produces;
|
import javax.ws.rs.Produces;
|
||||||
import javax.ws.rs.QueryParam;
|
import javax.ws.rs.QueryParam;
|
||||||
import javax.ws.rs.WebApplicationException;
|
|
||||||
import javax.ws.rs.container.ContainerRequestContext;
|
import javax.ws.rs.container.ContainerRequestContext;
|
||||||
import javax.ws.rs.core.Context;
|
import javax.ws.rs.core.Context;
|
||||||
import javax.ws.rs.core.MediaType;
|
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.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
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.PaymentMethod;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
|
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
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.ClientPlatform;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||||
|
@ -123,20 +117,13 @@ public class SubscriptionController {
|
||||||
private final BraintreeManager braintreeManager;
|
private final BraintreeManager braintreeManager;
|
||||||
private final ServerZkReceiptOperations zkReceiptOperations;
|
private final ServerZkReceiptOperations zkReceiptOperations;
|
||||||
private final IssuedReceiptsManager issuedReceiptsManager;
|
private final IssuedReceiptsManager issuedReceiptsManager;
|
||||||
private final OneTimeDonationsManager oneTimeDonationsManager;
|
|
||||||
private final BadgeTranslator badgeTranslator;
|
private final BadgeTranslator badgeTranslator;
|
||||||
private final LevelTranslator levelTranslator;
|
private final LevelTranslator levelTranslator;
|
||||||
private final BankMandateTranslator bankMandateTranslator;
|
private final BankMandateTranslator bankMandateTranslator;
|
||||||
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class,
|
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued");
|
||||||
"invalidAcceptLanguage");
|
static final String PROCESSOR_TAG_NAME = "processor";
|
||||||
private static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued");
|
static final String TYPE_TAG_NAME = "type";
|
||||||
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";
|
|
||||||
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
|
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
|
||||||
private static final String EURO_CURRENCY_CODE = "EUR";
|
|
||||||
|
|
||||||
public SubscriptionController(
|
public SubscriptionController(
|
||||||
@Nonnull Clock clock,
|
@Nonnull Clock clock,
|
||||||
|
@ -147,7 +134,6 @@ public class SubscriptionController {
|
||||||
@Nonnull BraintreeManager braintreeManager,
|
@Nonnull BraintreeManager braintreeManager,
|
||||||
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
|
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
|
||||||
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
|
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
|
||||||
@Nonnull OneTimeDonationsManager oneTimeDonationsManager,
|
|
||||||
@Nonnull BadgeTranslator badgeTranslator,
|
@Nonnull BadgeTranslator badgeTranslator,
|
||||||
@Nonnull LevelTranslator levelTranslator,
|
@Nonnull LevelTranslator levelTranslator,
|
||||||
@Nonnull BankMandateTranslator bankMandateTranslator) {
|
@Nonnull BankMandateTranslator bankMandateTranslator) {
|
||||||
|
@ -159,7 +145,6 @@ public class SubscriptionController {
|
||||||
this.braintreeManager = Objects.requireNonNull(braintreeManager);
|
this.braintreeManager = Objects.requireNonNull(braintreeManager);
|
||||||
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
|
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
|
||||||
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
|
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
|
||||||
this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager);
|
|
||||||
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
|
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
|
||||||
this.levelTranslator = Objects.requireNonNull(levelTranslator);
|
this.levelTranslator = Objects.requireNonNull(levelTranslator);
|
||||||
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
|
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
|
||||||
|
@ -390,7 +375,7 @@ public class SubscriptionController {
|
||||||
|
|
||||||
return updatedRecordFuture.thenCompose(
|
return updatedRecordFuture.thenCompose(
|
||||||
updatedRecord -> {
|
updatedRecord -> {
|
||||||
final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream()
|
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
|
||||||
.filter(l -> !"*".equals(l.getLanguage()))
|
.filter(l -> !"*".equals(l.getLanguage()))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(Locale.US);
|
.orElse(Locale.US);
|
||||||
|
@ -598,7 +583,7 @@ public class SubscriptionController {
|
||||||
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetSubscriptionConfigurationResponse.class)))
|
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetSubscriptionConfigurationResponse.class)))
|
||||||
public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
|
public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
|
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
|
||||||
return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build();
|
return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -609,7 +594,7 @@ public class SubscriptionController {
|
||||||
public CompletableFuture<Response> getBankMandate(final @Context ContainerRequestContext containerRequestContext,
|
public CompletableFuture<Response> getBankMandate(final @Context ContainerRequestContext containerRequestContext,
|
||||||
final @PathParam("bankTransferType") BankTransferType bankTransferType) {
|
final @PathParam("bankTransferType") BankTransferType bankTransferType) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
|
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
|
||||||
return Response.ok(new GetBankMandateResponse(
|
return Response.ok(new GetBankMandateResponse(
|
||||||
bankMandateTranslator.translate(acceptableLanguages, bankTransferType))).build();
|
bankMandateTranslator.translate(acceptableLanguages, bankTransferType))).build();
|
||||||
});
|
});
|
||||||
|
@ -617,298 +602,6 @@ public class SubscriptionController {
|
||||||
|
|
||||||
public record GetBankMandateResponse(String mandate) {}
|
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(
|
public record GetSubscriptionInformationResponse(
|
||||||
SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,
|
SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,
|
||||||
@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {
|
@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
|
@Nullable
|
||||||
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
|
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -133,7 +133,6 @@ public class BraintreeManager implements SubscriptionProcessorManager {
|
||||||
return paymentMethod == PaymentMethod.PAYPAL;
|
return paymentMethod == PaymentMethod.PAYPAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
|
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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) {}
|
|
@ -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,
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ package org.whispersystems.textsecuregcm.subscriptions;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
@ -22,8 +21,6 @@ public interface SubscriptionProcessorManager {
|
||||||
|
|
||||||
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
|
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
|
||||||
|
|
||||||
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
|
|
||||||
|
|
||||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
|
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
|
||||||
|
|
||||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||||
|
@ -58,21 +55,6 @@ public interface SubscriptionProcessorManager {
|
||||||
|
|
||||||
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscription);
|
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 {
|
enum SubscriptionStatus {
|
||||||
/**
|
/**
|
||||||
* The subscription is in good standing and the most recent payment was successful.
|
* The subscription is in good standing and the most recent payment was successful.
|
||||||
|
|
|
@ -7,15 +7,29 @@ package org.whispersystems.textsecuregcm.util;
|
||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
import static java.util.Objects.requireNonNull;
|
||||||
|
|
||||||
|
import com.google.common.net.HttpHeaders;
|
||||||
import io.dropwizard.auth.basic.BasicCredentials;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.annotation.Nonnull;
|
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.auth.ExternalServiceCredentials;
|
||||||
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
|
|
||||||
public final class HeaderUtils {
|
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_AGENT = "X-Signal-Agent";
|
||||||
|
|
||||||
public static final String X_SIGNAL_KEY = "X-Signal-Key";
|
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";
|
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() {
|
private HeaderUtils() {
|
||||||
// utility class
|
// utility class
|
||||||
}
|
}
|
||||||
|
@ -46,9 +63,8 @@ public final class HeaderUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a Base64-encoded value of the `Authorization` header
|
* Parses a Base64-encoded value of the `Authorization` header in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`. Note:
|
||||||
* in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`.
|
* parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}.
|
||||||
* Note: parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}.
|
|
||||||
*/
|
*/
|
||||||
public static Optional<BasicCredentials> basicCredentialsFromAuthHeader(final String authHeader) {
|
public static Optional<BasicCredentials> basicCredentialsFromAuthHeader(final String authHeader) {
|
||||||
final int space = authHeader.indexOf(' ');
|
final int space = authHeader.indexOf(' ');
|
||||||
|
@ -78,4 +94,24 @@ public final class HeaderUtils {
|
||||||
final String password = decoded.substring(i + 1);
|
final String password = decoded.substring(i + 1);
|
||||||
return Optional.of(new BasicCredentials(username, password));
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,7 +82,9 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
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 BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
||||||
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
|
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
|
||||||
CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS,
|
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()
|
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
|
||||||
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
||||||
.addProvider(AuthHelper.getAuthFilter())
|
.addProvider(AuthHelper.getAuthFilter())
|
||||||
|
@ -123,6 +127,7 @@ class SubscriptionControllerTest {
|
||||||
.setMapper(SystemMapper.jsonMapper())
|
.setMapper(SystemMapper.jsonMapper())
|
||||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||||
.addResource(SUBSCRIPTION_CONTROLLER)
|
.addResource(SUBSCRIPTION_CONTROLLER)
|
||||||
|
.addResource(ONE_TIME_CONTROLLER)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
|
@ -280,10 +285,10 @@ class SubscriptionControllerTest {
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
|
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",
|
"id",
|
||||||
Collections.emptyMap(),
|
Collections.emptyMap(),
|
||||||
SubscriptionProcessorManager.PaymentStatus.FAILED,
|
PaymentStatus.FAILED,
|
||||||
Instant.now(),
|
Instant.now(),
|
||||||
chargeFailure)
|
chargeFailure)
|
||||||
));
|
));
|
||||||
|
@ -299,7 +304,7 @@ class SubscriptionControllerTest {
|
||||||
assertThat(response.getStatus()).isEqualTo(402);
|
assertThat(response.getStatus()).isEqualTo(402);
|
||||||
|
|
||||||
if (expectChargeFailure) {
|
if (expectChargeFailure) {
|
||||||
assertThat(response.readEntity(SubscriptionController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure);
|
assertThat(response.readEntity(OneTimeDonationController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure);
|
||||||
} else {
|
} else {
|
||||||
assertThat(response.readEntity(String.class)).isEqualTo("{}");
|
assertThat(response.readEntity(String.class)).isEqualTo("{}");
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue