diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index a580728dc..080d03819 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -140,6 +140,7 @@ import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptio import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper; import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper; import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener; @@ -858,6 +859,7 @@ public class WhisperServerService extends Application { environment.jersey().register(exceptionMapper); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index 0e3488c44..14f20c4bd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -89,6 +89,7 @@ import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; @@ -1078,48 +1079,6 @@ public class SubscriptionController { } } - public static class ChargeFailure { - private final String code; - private final String message; - private final String outcomeNetworkStatus; - private final String outcomeReason; - private final String outcomeType; - - @JsonCreator - public ChargeFailure( - @JsonProperty("code") String code, - @JsonProperty("message") String message, - @JsonProperty("outcomeNetworkStatus") String outcomeNetworkStatus, - @JsonProperty("outcomeReason") String outcomeReason, - @JsonProperty("outcomeType") String outcomeType) { - this.code = code; - this.message = message; - this.outcomeNetworkStatus = outcomeNetworkStatus; - this.outcomeReason = outcomeReason; - this.outcomeType = outcomeType; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } - - public String getOutcomeNetworkStatus() { - return outcomeNetworkStatus; - } - - public String getOutcomeReason() { - return outcomeReason; - } - - public String getOutcomeType() { - return outcomeType; - } - } - private final Subscription subscription; private final ChargeFailure chargeFailure; @@ -1158,31 +1117,20 @@ public class SubscriptionController { final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> - manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> { - final GetSubscriptionInformationResponse.ChargeFailure chargeFailure = Optional.ofNullable(subscriptionInformation.chargeFailure()) - .map(chargeFailure1 -> new GetSubscriptionInformationResponse.ChargeFailure( - subscriptionInformation.chargeFailure().code(), - subscriptionInformation.chargeFailure().message(), - subscriptionInformation.chargeFailure().outcomeNetworkStatus(), - subscriptionInformation.chargeFailure().outcomeReason(), - subscriptionInformation.chargeFailure().outcomeType() - )) - .orElse(null); - return Response.ok( - new GetSubscriptionInformationResponse( - new GetSubscriptionInformationResponse.Subscription( - subscriptionInformation.level(), - subscriptionInformation.billingCycleAnchor(), - subscriptionInformation.endOfCurrentPeriod(), - subscriptionInformation.active(), - subscriptionInformation.cancelAtPeriodEnd(), - subscriptionInformation.price().currency(), - subscriptionInformation.price().amount(), - subscriptionInformation.status().getApiValue(), - manager.getProcessor()), - chargeFailure - )).build(); - })); + manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> Response.ok( + new GetSubscriptionInformationResponse( + new GetSubscriptionInformationResponse.Subscription( + subscriptionInformation.level(), + subscriptionInformation.billingCycleAnchor(), + subscriptionInformation.endOfCurrentPeriod(), + subscriptionInformation.active(), + subscriptionInformation.cancelAtPeriodEnd(), + subscriptionInformation.price().currency(), + subscriptionInformation.price().amount(), + subscriptionInformation.status().getApiValue(), + manager.getProcessor()), + subscriptionInformation.chargeFailure() + )).build())); }); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionProcessorExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionProcessorExceptionMapper.java new file mode 100644 index 000000000..73571ac50 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionProcessorExceptionMapper.java @@ -0,0 +1,26 @@ +/* + * 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 { + + 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(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index 248b19567..db1eeac80 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -49,6 +49,8 @@ public class BraintreeManager implements SubscriptionProcessorManager { private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class); + private static final String GENERIC_DECLINED_PROCESSOR_CODE = "2046"; + private static final String PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE = "2074"; private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094"; private final BraintreeGateway braintreeGateway; private final BraintreeGraphqlClient braintreeGraphqlClient; @@ -184,11 +186,18 @@ public class BraintreeManager implements SubscriptionProcessorManager { new PayPalChargeSuccessDetails(successfulTx.getGraphQLId())); } - logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode()); + return switch (unsuccessfulTx.getProcessorResponseCode()) { + case GENERIC_DECLINED_PROCESSOR_CODE, PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE -> + CompletableFuture.failedFuture( + new SubscriptionProcessorException(getProcessor(), createChargeFailure(unsuccessfulTx))); - return CompletableFuture.failedFuture( - new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); + default -> { + logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode()); + yield CompletableFuture.failedFuture( + new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); + } + }; }, executor)); } @@ -240,12 +249,6 @@ public class BraintreeManager implements SubscriptionProcessorManager { } - private void assertResultSuccess(Result result) throws CompletionException { - if (!result.isSuccess()) { - throw new CompletionException(new BraintreeException(result.getMessage())); - } - } - @Override public CompletableFuture createCustomer(final byte[] subscriberUser) { return CompletableFuture.supplyAsync(() -> { @@ -258,7 +261,9 @@ public class BraintreeManager implements SubscriptionProcessorManager { } }, executor) .thenApply(result -> { - assertResultSuccess(result); + if (!result.isSuccess()) { + throw new CompletionException(new BraintreeException(result.getMessage())); + } return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE); }); @@ -336,7 +341,19 @@ public class BraintreeManager implements SubscriptionProcessorManager { .done() ); - assertResultSuccess(result); + if (!result.isSuccess()) { + final CompletionException completionException; + if (result.getTarget() != null) { + completionException = result.getTarget().getTransactions().stream().findFirst() + .map(transaction -> new CompletionException( + new SubscriptionProcessorException(getProcessor(), createChargeFailure(transaction)))) + .orElseGet(() -> new CompletionException(new BraintreeException(result.getMessage()))); + } else { + completionException = new CompletionException(new BraintreeException(result.getMessage())); + } + + throw completionException; + } return result.getTarget(); })); @@ -358,7 +375,7 @@ public class BraintreeManager implements SubscriptionProcessorManager { } // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and - // and not prorated. Braintree subscriptions cannot change their next billing date, + // not prorated. Braintree subscriptions cannot change their next billing date, // so we must end the existing one and create a new one return cancelSubscriptionAtEndOfCurrentPeriod(subscription) .thenCompose(ignored -> { @@ -413,37 +430,13 @@ public class BraintreeManager implements SubscriptionProcessorManager { final Instant anchor = subscription.getFirstBillingDate().toInstant(); final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant(); - final Optional maybeTransaction = getLatestTransactionForSubscription(subscription); - - final ChargeFailure chargeFailure = maybeTransaction.map(transaction -> { - + final ChargeFailure chargeFailure = getLatestTransactionForSubscription(subscription).map(transaction -> { if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { return null; } - - final String code; - final String message; - if (transaction.getProcessorResponseCode() != null) { - code = transaction.getProcessorResponseCode(); - message = transaction.getProcessorResponseText(); - } else if (transaction.getGatewayRejectionReason() != null) { - code = "gateway"; - message = transaction.getGatewayRejectionReason().toString(); - } else { - code = "unknown"; - message = "unknown"; - } - - return new ChargeFailure( - code, - message, - null, - null, - null); - + return createChargeFailure(transaction); }).orElse(null); - return new SubscriptionInformation( new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT), SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())), @@ -458,6 +451,29 @@ public class BraintreeManager implements SubscriptionProcessorManager { }, executor); } + private ChargeFailure createChargeFailure(Transaction transaction) { + + final String code; + final String message; + if (transaction.getProcessorResponseCode() != null) { + code = transaction.getProcessorResponseCode(); + message = transaction.getProcessorResponseText(); + } else if (transaction.getGatewayRejectionReason() != null) { + code = "gateway"; + message = transaction.getGatewayRejectionReason().toString(); + } else { + code = "unknown"; + message = "unknown"; + } + + return new ChargeFailure( + code, + message, + null, + null, + null); + } + @Override public CompletableFuture cancelAllActiveSubscriptions(String customerId) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java new file mode 100644 index 000000000..55b56bd2a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import javax.annotation.Nullable; + +public record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus, + @Nullable String outcomeReason, @Nullable String outcomeType) { + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index 23fa3893f..57b4fb933 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.subscriptions; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.stripe.StripeClient; +import com.stripe.exception.CardException; import com.stripe.exception.StripeException; import com.stripe.model.Charge; import com.stripe.model.Customer; @@ -268,6 +269,18 @@ public class StripeManager implements SubscriptionProcessorManager { .create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( customerId, lastSubscriptionCreatedAt))); } catch (StripeException e) { + + if (e instanceof CardException ce) { + throw new CompletionException(new SubscriptionProcessorException(getProcessor(), + new ChargeFailure( + StringUtils.defaultIfBlank(ce.getDeclineCode(), ce.getCode()), + e.getStripeError().getMessage(), + null, + null, + null + ))); + } + throw new CompletionException(e); } }, executor) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorException.java new file mode 100644 index 000000000..aa2aa2e7e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionProcessorException extends Exception { + + private final SubscriptionProcessor processor; + private final ChargeFailure chargeFailure; + + public SubscriptionProcessorException(final SubscriptionProcessor processor, + final ChargeFailure chargeFailure) { + this.processor = processor; + this.chargeFailure = chargeFailure; + } + + public SubscriptionProcessor getProcessor() { + return processor; + } + + public ChargeFailure getChargeFailure() { + return chargeFailure; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java index 12c28a284..02bc5d3d7 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -155,11 +155,6 @@ public interface SubscriptionProcessorManager { } - record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus, - @Nullable String outcomeReason, @Nullable String outcomeType) { - - } - record ReceiptItem(String itemId, Instant expiration, long level) { } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index ae6369c3d..4f6d5c086 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -42,6 +42,7 @@ import java.util.function.Predicate; import java.util.stream.Stream; import javax.ws.rs.client.Entity; import javax.ws.rs.core.Response; +import org.assertj.core.api.InstanceOfAssertFactories; import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; import org.junit.jupiter.api.BeforeEach; @@ -63,12 +64,15 @@ import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSu import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.util.SystemMapper; @@ -112,6 +116,7 @@ class SubscriptionControllerTest { .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProvider(AuthHelper.getAuthFilter()) .addProvider(CompletionExceptionMapper.class) + .addProvider(SubscriptionProcessorExceptionMapper.class) .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(Set.of( AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) .setMapper(SystemMapper.jsonMapper()) @@ -189,6 +194,32 @@ class SubscriptionControllerTest { assertThat(response.getStatus()).isEqualTo(422); } + @Test + void confirmPaypalBoostProcessorError() { + + when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(), + anyLong())) + .thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(SubscriptionProcessor.BRAINTREE, + new ChargeFailure("2046", "Declined", null, null, null)))); + + final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/confirm") + .request() + .post(Entity.json(Map.of("payerId", "payer123", + "paymentId", "PAYID-456", + "paymentToken", "EC-789", + "currency", "usd", + "amount", 123))); + + assertThat(response.getStatus()).isEqualTo(SubscriptionProcessorExceptionMapper.EXTERNAL_SERVICE_ERROR_STATUS_CODE); + + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("processor")).isEqualTo("BRAINTREE"); + assertThat(responseMap.get("chargeFailure")).asInstanceOf( + InstanceOfAssertFactories.map(String.class, Object.class)) + .extracting("code") + .isEqualTo("2046"); + } + @Test void createBoostReceiptNoRequest() { final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") @@ -230,7 +261,7 @@ class SubscriptionControllerTest { } @Test - void success() { + void createSubscriptionSuccess() { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) .thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class))); @@ -244,6 +275,30 @@ class SubscriptionControllerTest { assertThat(response.getStatus()).isEqualTo(200); } + @Test + void createSubscriptionProcessorDeclined() { + when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) + .thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(SubscriptionProcessor.STRIPE, + new ChargeFailure("card_declined", "Insufficient funds", null, null, null)))); + + final String level = String.valueOf(levelId); + final String idempotencyKey = UUID.randomUUID().toString(); + final Response response = RESOURCE_EXTENSION.target( + String.format("/v1/subscription/%s/level/%s/%s/%s", subscriberId, level, currency, idempotencyKey)) + .request() + .put(Entity.json("")); + + assertThat(response.getStatus()).isEqualTo( + SubscriptionProcessorExceptionMapper.EXTERNAL_SERVICE_ERROR_STATUS_CODE); + + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("processor")).isEqualTo("STRIPE"); + assertThat(responseMap.get("chargeFailure")).asInstanceOf( + InstanceOfAssertFactories.map(String.class, Object.class)) + .extracting("code") + .isEqualTo("card_declined"); + } + @Test void missingCustomerId() { final byte[] subscriberUserAndKey = new byte[32];