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 475a90a33..bcaa2a5a6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -802,9 +802,11 @@ public class SubscriptionController { public SubscriptionProcessor processor = SubscriptionProcessor.STRIPE; } - public record CreateBoostReceiptCredentialsResponse(byte[] receiptCredentialResponse) { + public record CreateBoostReceiptCredentialsSuccessResponse(byte[] receiptCredentialResponse) { } + public record CreateBoostReceiptCredentialsErrorResponse(@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {} + @POST @Path("/boost/receipt_credentials") @Consumes(MediaType.APPLICATION_JSON) @@ -824,7 +826,8 @@ public class SubscriptionController { case PROCESSING -> throw new WebApplicationException(Status.NO_CONTENT); case SUCCEEDED -> { } - default -> throw new WebApplicationException(Status.PAYMENT_REQUIRED); + default -> throw new WebApplicationException(Response.status(Status.PAYMENT_REQUIRED) + .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build()); } long level = oneTimeDonationConfiguration.boost().level(); @@ -875,7 +878,7 @@ public class SubscriptionController { Tag.of(TYPE_TAG_NAME, "boost"), UserAgentTagUtil.getPlatformTag(userAgent))) .increment(); - return Response.ok(new CreateBoostReceiptCredentialsResponse(receiptCredentialResponse.serialize())) + return Response.ok(new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize())) .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 3d039029f..4c30a4de8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -117,11 +117,15 @@ public class BraintreeManager implements SubscriptionProcessorManager { return CompletableFuture.supplyAsync(() -> { try { final Transaction transaction = braintreeGateway.transaction().find(paymentId); - + ChargeFailure chargeFailure = null; + if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { + chargeFailure = createChargeFailure(transaction); + } return new PaymentDetails(transaction.getGraphQLId(), transaction.getCustomFields(), getPaymentStatus(transaction.getStatus()), - transaction.getCreatedAt().toInstant()); + transaction.getCreatedAt().toInstant(), + chargeFailure); } catch (final NotFoundException e) { return null; @@ -433,7 +437,7 @@ public class BraintreeManager implements SubscriptionProcessorManager { if (latestTransaction.isPresent()){ paymentProcessing = isPaymentProcessing(latestTransaction.get().getStatus()); - if (!getPaymentStatus(latestTransaction.get().getStatus()).equals(PaymentStatus.SUCCEEDED)) { + if (getPaymentStatus(latestTransaction.get().getStatus()) != PaymentStatus.SUCCEEDED) { chargeFailure = createChargeFailure(latestTransaction.get()); } } @@ -470,7 +474,10 @@ public class BraintreeManager implements SubscriptionProcessorManager { final String code; final String message; - if (transaction.getProcessorResponseCode() != null) { + if (transaction.getStatus() == Transaction.Status.VOIDED) { + code = "voided"; + message = "voided"; + } else if (transaction.getProcessorResponseCode() != null) { code = transaction.getProcessorResponseCode(); message = transaction.getProcessorResponseText(); } else if (transaction.getGatewayRejectionReason() != null) { 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 35b6fa921..a988a4601 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -28,6 +28,7 @@ import com.stripe.param.CustomerUpdateParams; import com.stripe.param.CustomerUpdateParams.InvoiceSettings; import com.stripe.param.InvoiceListParams; import com.stripe.param.PaymentIntentCreateParams; +import com.stripe.param.PaymentIntentRetrieveParams; import com.stripe.param.PriceRetrieveParams; import com.stripe.param.SetupIntentCreateParams; import com.stripe.param.SubscriptionCancelParams; @@ -216,12 +217,23 @@ public class StripeManager implements SubscriptionProcessorManager { public CompletableFuture getPaymentDetails(String paymentIntentId) { return CompletableFuture.supplyAsync(() -> { try { - final PaymentIntent paymentIntent = stripeClient.paymentIntents().retrieve(paymentIntentId, commonOptions()); + final PaymentIntentRetrieveParams params = PaymentIntentRetrieveParams.builder() + .addExpand("latest_charge").build(); + final PaymentIntent paymentIntent = stripeClient.paymentIntents().retrieve(paymentIntentId, params, commonOptions()); + + ChargeFailure chargeFailure = null; + if (paymentIntent.getLatestChargeObject() != null) { + final Charge charge = paymentIntent.getLatestChargeObject(); + if (charge.getFailureCode() != null || charge.getFailureMessage() != null) { + chargeFailure = createChargeFailure(charge); + } + } return new PaymentDetails(paymentIntent.getId(), paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(), getPaymentStatusForStatus(paymentIntent.getStatus()), - Instant.ofEpochSecond(paymentIntent.getCreated())); + Instant.ofEpochSecond(paymentIntent.getCreated()), + chargeFailure); } catch (StripeException e) { if (e.getStatusCode() == 404) { return null; @@ -479,6 +491,16 @@ public class StripeManager implements SubscriptionProcessorManager { }, executor); } + private static ChargeFailure createChargeFailure(final Charge charge) { + Charge.Outcome outcome = charge.getOutcome(); + return new ChargeFailure( + charge.getFailureCode(), + charge.getFailureMessage(), + outcome != null ? outcome.getNetworkStatus() : null, + outcome != null ? outcome.getReason() : null, + outcome != null ? outcome.getType() : null); + } + @Override public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { @@ -497,13 +519,7 @@ public class StripeManager implements SubscriptionProcessorManager { if (invoice.getChargeObject() != null) { final Charge charge = invoice.getChargeObject(); if (charge.getFailureCode() != null || charge.getFailureMessage() != null) { - Charge.Outcome outcome = charge.getOutcome(); - chargeFailure = new ChargeFailure( - charge.getFailureCode(), - charge.getFailureMessage(), - outcome != null ? outcome.getNetworkStatus() : null, - outcome != null ? outcome.getReason() : null, - outcome != null ? outcome.getType() : null); + chargeFailure = createChargeFailure(charge); } if (charge.getPaymentMethodDetails() != null 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 e82d6e5bc..63d391b8f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -60,7 +60,8 @@ public interface SubscriptionProcessorManager { record PaymentDetails(String id, Map customMetadata, PaymentStatus status, - Instant created) { + Instant created, + @Nullable ChargeFailure chargeFailure) { } 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 6ca617adc..4ffbed3bb 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -31,6 +31,7 @@ import java.time.Clock; import java.time.Instant; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -267,6 +268,47 @@ class SubscriptionControllerTest { assertThat(response.getStatus()).isEqualTo(422); } + @ParameterizedTest + @MethodSource + void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) { + when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new SubscriptionProcessorManager.PaymentDetails( + "id", + Collections.emptyMap(), + SubscriptionProcessorManager.PaymentStatus.FAILED, + Instant.now(), + chargeFailure) + )); + Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") + .request() + .post(Entity.json(""" + { + "paymentIntentId": "foo", + "receiptCredentialRequest": "abcd", + "processor": "STRIPE" + } + """)); + assertThat(response.getStatus()).isEqualTo(402); + + if (expectChargeFailure) { + assertThat(response.readEntity(SubscriptionController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure); + } else { + assertThat(response.readEntity(String.class)).isEqualTo("{}"); + } + } + + private static Stream createBoostReceiptPaymentRequired() { + return Stream.of( + Arguments.of(new ChargeFailure( + "generic_decline", + "some failure message", + null, + null, + null + ), true), + Arguments.of(null, false) + ); + } + @Test void confirmPaypalBoostProcessorError() {