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.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) {
|
||||
|
|
|
@ -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(),
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.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.
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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("{}");
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue