remove HTTP layer exceptions from Stripe/Braintree managers

This commit is contained in:
Ravi Khadiwala 2024-09-19 17:12:45 -05:00 committed by ravi-signal
parent 50bd30fb1f
commit 237d0fd4e2
8 changed files with 123 additions and 108 deletions

View File

@ -182,7 +182,6 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper
import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener;
import org.whispersystems.textsecuregcm.metrics.MetricsHttpChannelListener;
@ -1217,7 +1216,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ImpossiblePhoneNumberExceptionMapper(),
new NonNormalizedPhoneNumberExceptionMapper(),
new RegistrationServiceSenderExceptionMapper(),
new SubscriptionProcessorExceptionMapper(),
new SubscriptionExceptionMapper(),
new JsonMappingExceptionMapper()
).forEach(exceptionMapper -> {

View File

@ -5,17 +5,48 @@
package org.whispersystems.textsecuregcm.mappers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.jersey.errors.ErrorMessage;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import java.util.Map;
public class SubscriptionExceptionMapper implements ExceptionMapper<SubscriptionException> {
@VisibleForTesting
public static final int PROCESSOR_ERROR_STATUS_CODE = 440;
@Override
public Response toResponse(final SubscriptionException exception) {
// Some exceptions have specific error body formats
if (exception instanceof SubscriptionException.AmountTooSmall e) {
return Response
.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "amount_too_small"))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ProcessorException e) {
return Response.status(PROCESSOR_ERROR_STATUS_CODE)
.entity(Map.of(
"processor", e.getProcessor().name(),
"chargeFailure", e.getChargeFailure()
))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ChargeFailurePaymentRequired e) {
return Response
.status(Response.Status.PAYMENT_REQUIRED)
.entity(Map.of("chargeFailure", e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
// Otherwise, we'll return a generic error message WebApplicationException, with a detailed error if one is provided
final Response.Status status = (switch (exception) {
case SubscriptionException.NotFound e -> Response.Status.NOT_FOUND;
case SubscriptionException.Forbidden e -> Response.Status.FORBIDDEN;
@ -36,5 +67,6 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
.fromResponse(wae.getResponse())
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorMessage(wae.getResponse().getStatus(), wae.getLocalizedMessage())).build();
}
}

View File

@ -1,26 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.mappers;
import java.util.Map;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
public class SubscriptionProcessorExceptionMapper implements ExceptionMapper<SubscriptionProcessorException> {
public static final int EXTERNAL_SERVICE_ERROR_STATUS_CODE = 440;
@Override
public Response toResponse(final SubscriptionProcessorException exception) {
return Response.status(EXTERNAL_SERVICE_ERROR_STATUS_CODE)
.entity(Map.of(
"processor", exception.getProcessor().name(),
"chargeFailure", exception.getChargeFailure()
))
.build();
}
}

View File

@ -6,6 +6,8 @@ package org.whispersystems.textsecuregcm.storage;
import java.util.Optional;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
public class SubscriptionException extends Exception {
@ -53,39 +55,91 @@ public class SubscriptionException extends Exception {
}
public static class InvalidLevel extends InvalidArguments {
public InvalidLevel() {
super(null, null);
}
}
public static class AmountTooSmall extends InvalidArguments {
public AmountTooSmall() {
super(null, null);
}
}
public static class PaymentRequiresAction extends InvalidArguments {
public PaymentRequiresAction(String message) {
super(message, null);
}
public PaymentRequiresAction() {
super(null, null);
}
}
public static class PaymentRequired extends SubscriptionException {
public PaymentRequired() {
super(null, null);
}
public PaymentRequired(String message) {
super(null, message);
}
}
public static class ChargeFailurePaymentRequired extends SubscriptionException {
private final ChargeFailure chargeFailure;
public ChargeFailurePaymentRequired(final ChargeFailure chargeFailure) {
super(null, null);
this.chargeFailure = chargeFailure;
}
public ChargeFailure getChargeFailure() {
return chargeFailure;
}
}
public static class ProcessorException extends SubscriptionException {
private final PaymentProvider processor;
private final ChargeFailure chargeFailure;
public ProcessorException(final PaymentProvider processor, final ChargeFailure chargeFailure) {
super(null, null);
this.processor = processor;
this.chargeFailure = chargeFailure;
}
public PaymentProvider getProcessor() {
return processor;
}
public ChargeFailure getChargeFailure() {
return chargeFailure;
}
}
/**
* Attempted to retrieve a receipt for a subscription that hasn't yet been charged or the invoice is in the open state
* Attempted to retrieve a receipt for a subscription that hasn't yet been charged or the invoice is in the open
* state
*/
public static class ReceiptRequestedForOpenPayment extends SubscriptionException {
public ReceiptRequestedForOpenPayment() {
super(null, null);
}
}
public static class ProcessorConflict extends SubscriptionException {
public ProcessorConflict() {
super(null, null);
}
public ProcessorConflict(final String message) {
super(null, message);
}

View File

@ -23,6 +23,7 @@ import com.google.cloud.pubsub.v1.PublisherInterface;
import com.google.common.annotations.VisibleForTesting;
import com.google.pubsub.v1.PubsubMessage;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
@ -39,9 +40,6 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import javax.annotation.Nullable;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
@ -199,8 +197,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
);
if (search.getMaximumSize() == 0) {
return CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
return CompletableFuture.failedFuture(ExceptionUtils.wrap(new IOException()));
}
final Transaction successfulTx = search.getFirst();
@ -214,13 +211,12 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
return switch (unsuccessfulTx.getProcessorResponseCode()) {
case GENERIC_DECLINED_PROCESSOR_CODE, PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE ->
CompletableFuture.failedFuture(
new SubscriptionProcessorException(getProvider(), createChargeFailure(unsuccessfulTx)));
new SubscriptionException.ProcessorException(getProvider(), createChargeFailure(unsuccessfulTx)));
default -> {
logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode());
yield CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
yield CompletableFuture.failedFuture(ExceptionUtils.wrap(new IOException()));
}
};
}, executor));
@ -391,7 +387,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
return getDefaultPaymentMethod(customerId)
.thenCompose(paymentMethod -> {
if (paymentMethod == null) {
throw new ClientErrorException(Response.Status.CONFLICT);
throw ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict());
}
final Optional<Subscription> maybeExistingSubscription = paymentMethod.getSubscriptions().stream()
@ -426,7 +422,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (result.getTarget() != null) {
completionException = result.getTarget().getTransactions().stream().findFirst()
.map(transaction -> new CompletionException(
new SubscriptionProcessorException(getProvider(), createChargeFailure(transaction))))
new SubscriptionException.ProcessorException(getProvider(), createChargeFailure(transaction))))
.orElseGet(() -> new CompletionException(new BraintreeException(result.getMessage())));
} else {
completionException = new CompletionException(new BraintreeException(result.getMessage()));
@ -460,9 +456,8 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
return cancelSubscriptionAtEndOfCurrentPeriod(subscription)
.thenCompose(ignored -> {
final Transaction transaction = getLatestTransactionForSubscription(subscription).orElseThrow(
() -> new ClientErrorException(
Response.Status.CONFLICT));
final Transaction transaction = getLatestTransactionForSubscription(subscription)
.orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict()));
final Customer customer = transaction.getCustomer();
@ -615,10 +610,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals(SubscriptionStatus.PAST_DUE)) {
throw ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment());
}
throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
.entity(Map.of("chargeFailure", createChargeFailure(transaction)))
.build());
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(transaction)));
}
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();

View File

@ -40,6 +40,7 @@ import com.stripe.param.SubscriptionRetrieveParams;
import com.stripe.param.SubscriptionUpdateParams;
import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;
import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
@ -55,6 +56,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
@ -64,12 +66,6 @@ import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -201,7 +197,8 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
/**
* Creates a payment intent. May throw a 400 WebApplicationException if the amount is too small.
* Creates a payment intent. May throw a
* {@link SubscriptionException.AmountTooSmall} if the amount is too small.
*/
public CompletableFuture<PaymentIntent> createPaymentIntent(final String currency,
final long amount,
@ -223,10 +220,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
return stripeClient.paymentIntents().create(builder.build(), commonOptions());
} catch (StripeException e) {
if ("amount_too_small".equalsIgnoreCase(e.getCode())) {
throw new WebApplicationException(Response
.status(Status.BAD_REQUEST)
.entity(Map.of("error", "amount_too_small"))
.build());
throw ExceptionUtils.wrap(new SubscriptionException.AmountTooSmall());
} else {
throw new CompletionException(e);
}
@ -303,7 +297,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
if (e instanceof CardException ce) {
throw new CompletionException(
new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e, ce)));
new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e, ce)));
}
throw new CompletionException(e);
@ -356,10 +350,10 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
} catch (StripeException e) {
if (e instanceof CardException ce) {
throw new CompletionException(
new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e, ce)));
throw ExceptionUtils.wrap(
new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e, ce)));
}
throw new CompletionException(e);
throw ExceptionUtils.wrap(e);
}
}, executor)
@ -385,8 +379,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
return getCustomer(customerId).thenCompose(customer -> {
if (customer == null) {
throw new InternalServerErrorException(
"no customer record found for id " + customerId);
throw ExceptionUtils.wrap(new IOException("no customer record found for id " + customerId));
}
return listNonCanceledSubscriptions(customer);
}).thenCompose(subscriptions -> {
@ -617,14 +610,15 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment()));
}
if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) {
final Response.ResponseBuilder responseBuilder = Response.status(Status.PAYMENT_REQUIRED);
if (latestSubscriptionInvoice.getChargeObject() != null) {
final Charge charge = latestSubscriptionInvoice.getChargeObject();
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
responseBuilder.entity(Map.of("chargeFailure", createChargeFailure(charge)));
}
}
throw new WebApplicationException(responseBuilder.build());
return CompletableFuture.failedFuture(ExceptionUtils.wrap(Optional
.ofNullable(latestSubscriptionInvoice.getChargeObject())
// If the charge object has a failure reason we can present to the user, create a detailed exception
.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)
.<SubscriptionException> map(charge -> new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(charge)))
// Otherwise, return a generic payment required error
.orElseGet(() -> new SubscriptionException.PaymentRequired())));
}
return getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> {
@ -688,15 +682,15 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
// This usually indicates that the client has made requests out of order, either by not confirming
// the SetupIntent or not having the user authorize the transaction.
logger.debug("setupIntent {} missing expected fields", setupIntentId);
throw new ClientErrorException(Status.CONFLICT);
throw ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict());
}
return setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit();
} catch (StripeException e) {
if (e.getStatusCode() == 404) {
throw new NotFoundException();
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
logger.error("unexpected error from Stripe when retrieving setupIntent {}", setupIntentId, e);
throw new CompletionException(e);
throw ExceptionUtils.wrap(e);
}
}, executor);
}

View File

@ -1,26 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
public class SubscriptionProcessorException extends Exception {
private final PaymentProvider processor;
private final ChargeFailure chargeFailure;
public SubscriptionProcessorException(final PaymentProvider processor,
final ChargeFailure chargeFailure) {
this.processor = processor;
this.chargeFailure = chargeFailure;
}
public PaymentProvider getProcessor() {
return processor;
}
public ChargeFailure getChargeFailure() {
return chargeFailure;
}
}

View File

@ -76,10 +76,10 @@ import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
@ -93,7 +93,6 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MockUtils;
@ -133,7 +132,6 @@ class SubscriptionControllerTest {
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter())
.addProvider(CompletionExceptionMapper.class)
.addProvider(SubscriptionProcessorExceptionMapper.class)
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.addProvider(SubscriptionExceptionMapper.class)
.setMapper(SystemMapper.jsonMapper())
@ -340,7 +338,7 @@ class SubscriptionControllerTest {
when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
anyLong(), any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.BRAINTREE,
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ProcessorException(PaymentProvider.BRAINTREE,
new ChargeFailure("2046", "Declined", null, null, null))));
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/confirm")
@ -351,7 +349,7 @@ class SubscriptionControllerTest {
"currency", "usd",
"amount", 123)));
assertThat(response.getStatus()).isEqualTo(SubscriptionProcessorExceptionMapper.EXTERNAL_SERVICE_ERROR_STATUS_CODE);
assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("BRAINTREE");
@ -419,7 +417,7 @@ class SubscriptionControllerTest {
@Test
void createSubscriptionProcessorDeclined() {
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.STRIPE,
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null))));
final String level = String.valueOf(levelId);
@ -429,8 +427,7 @@ class SubscriptionControllerTest {
.request()
.put(Entity.json(""));
assertThat(response.getStatus()).isEqualTo(
SubscriptionProcessorExceptionMapper.EXTERNAL_SERVICE_ERROR_STATUS_CODE);
assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("STRIPE");