Propagate certain subscription processor errors to client responses
This commit is contained in:
		
							parent
							
								
									2d187abf13
								
							
						
					
					
						commit
						b89e2e5355
					
				|  | @ -140,6 +140,7 @@ import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptio | ||||||
| import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; | import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; | ||||||
| import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper; | import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper; | ||||||
| import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; | import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; | ||||||
|  | import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; | import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; | ||||||
| import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | import org.whispersystems.textsecuregcm.metrics.MetricsUtil; | ||||||
| import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener; | import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener; | ||||||
|  | @ -858,6 +859,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration | ||||||
|         new ImpossiblePhoneNumberExceptionMapper(), |         new ImpossiblePhoneNumberExceptionMapper(), | ||||||
|         new NonNormalizedPhoneNumberExceptionMapper(), |         new NonNormalizedPhoneNumberExceptionMapper(), | ||||||
|         new RegistrationServiceSenderExceptionMapper(), |         new RegistrationServiceSenderExceptionMapper(), | ||||||
|  |         new SubscriptionProcessorExceptionMapper(), | ||||||
|         new JsonMappingExceptionMapper() |         new JsonMappingExceptionMapper() | ||||||
|     ).forEach(exceptionMapper -> { |     ).forEach(exceptionMapper -> { | ||||||
|       environment.jersey().register(exceptionMapper); |       environment.jersey().register(exceptionMapper); | ||||||
|  |  | ||||||
|  | @ -89,6 +89,7 @@ import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; | ||||||
| import org.whispersystems.textsecuregcm.storage.SubscriptionManager; | import org.whispersystems.textsecuregcm.storage.SubscriptionManager; | ||||||
| import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; | import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; | import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; | ||||||
|  | import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; | import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; | import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.StripeManager; | import org.whispersystems.textsecuregcm.subscriptions.StripeManager; | ||||||
|  | @ -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 Subscription subscription; | ||||||
|     private final ChargeFailure chargeFailure; |     private final ChargeFailure chargeFailure; | ||||||
| 
 | 
 | ||||||
|  | @ -1158,31 +1117,20 @@ public class SubscriptionController { | ||||||
|             final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); |             final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); | ||||||
| 
 | 
 | ||||||
|             return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> |             return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> | ||||||
|                     manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> { |                 manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> Response.ok( | ||||||
|                         final GetSubscriptionInformationResponse.ChargeFailure chargeFailure = Optional.ofNullable(subscriptionInformation.chargeFailure()) |                     new GetSubscriptionInformationResponse( | ||||||
|                                 .map(chargeFailure1 -> new GetSubscriptionInformationResponse.ChargeFailure( |                         new GetSubscriptionInformationResponse.Subscription( | ||||||
|                                         subscriptionInformation.chargeFailure().code(), |                             subscriptionInformation.level(), | ||||||
|                                         subscriptionInformation.chargeFailure().message(), |                             subscriptionInformation.billingCycleAnchor(), | ||||||
|                                         subscriptionInformation.chargeFailure().outcomeNetworkStatus(), |                             subscriptionInformation.endOfCurrentPeriod(), | ||||||
|                                         subscriptionInformation.chargeFailure().outcomeReason(), |                             subscriptionInformation.active(), | ||||||
|                                         subscriptionInformation.chargeFailure().outcomeType() |                             subscriptionInformation.cancelAtPeriodEnd(), | ||||||
|                                 )) |                             subscriptionInformation.price().currency(), | ||||||
|                                 .orElse(null); |                             subscriptionInformation.price().amount(), | ||||||
|                         return Response.ok( |                             subscriptionInformation.status().getApiValue(), | ||||||
|                                 new GetSubscriptionInformationResponse( |                             manager.getProcessor()), | ||||||
|                                     new GetSubscriptionInformationResponse.Subscription( |                         subscriptionInformation.chargeFailure() | ||||||
|                                         subscriptionInformation.level(), |                     )).build())); | ||||||
|                                         subscriptionInformation.billingCycleAnchor(), |  | ||||||
|                                         subscriptionInformation.endOfCurrentPeriod(), |  | ||||||
|                                         subscriptionInformation.active(), |  | ||||||
|                                         subscriptionInformation.cancelAtPeriodEnd(), |  | ||||||
|                                         subscriptionInformation.price().currency(), |  | ||||||
|                                         subscriptionInformation.price().amount(), |  | ||||||
|                                         subscriptionInformation.status().getApiValue(), |  | ||||||
|                                         manager.getProcessor()), |  | ||||||
|                                     chargeFailure |  | ||||||
|                                 )).build(); |  | ||||||
|                     })); |  | ||||||
|         }); |         }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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<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(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -49,6 +49,8 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
| 
 | 
 | ||||||
|   private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class); |   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 static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094"; | ||||||
|   private final BraintreeGateway braintreeGateway; |   private final BraintreeGateway braintreeGateway; | ||||||
|   private final BraintreeGraphqlClient braintreeGraphqlClient; |   private final BraintreeGraphqlClient braintreeGraphqlClient; | ||||||
|  | @ -184,11 +186,18 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
|                     new PayPalChargeSuccessDetails(successfulTx.getGraphQLId())); |                     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( |                 default -> { | ||||||
|                   new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); |                   logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode()); | ||||||
| 
 | 
 | ||||||
|  |                   yield CompletableFuture.failedFuture( | ||||||
|  |                       new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); | ||||||
|  |                 } | ||||||
|  |               }; | ||||||
|             }, executor)); |             }, 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 |   @Override | ||||||
|   public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) { |   public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) { | ||||||
|     return CompletableFuture.supplyAsync(() -> { |     return CompletableFuture.supplyAsync(() -> { | ||||||
|  | @ -258,7 +261,9 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
|           } |           } | ||||||
|         }, executor) |         }, executor) | ||||||
|         .thenApply(result -> { |         .thenApply(result -> { | ||||||
|           assertResultSuccess(result); |           if (!result.isSuccess()) { | ||||||
|  |             throw new CompletionException(new BraintreeException(result.getMessage())); | ||||||
|  |           } | ||||||
| 
 | 
 | ||||||
|           return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE); |           return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE); | ||||||
|         }); |         }); | ||||||
|  | @ -336,7 +341,19 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
|                     .done() |                     .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(); |                 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 |     // 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 |     // so we must end the existing one and create a new one | ||||||
|     return cancelSubscriptionAtEndOfCurrentPeriod(subscription) |     return cancelSubscriptionAtEndOfCurrentPeriod(subscription) | ||||||
|         .thenCompose(ignored -> { |         .thenCompose(ignored -> { | ||||||
|  | @ -413,37 +430,13 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
|       final Instant anchor = subscription.getFirstBillingDate().toInstant(); |       final Instant anchor = subscription.getFirstBillingDate().toInstant(); | ||||||
|       final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant(); |       final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant(); | ||||||
| 
 | 
 | ||||||
|       final Optional<Transaction> maybeTransaction = getLatestTransactionForSubscription(subscription); |       final ChargeFailure chargeFailure = getLatestTransactionForSubscription(subscription).map(transaction -> { | ||||||
| 
 |  | ||||||
|       final ChargeFailure chargeFailure = maybeTransaction.map(transaction -> { |  | ||||||
| 
 |  | ||||||
|         if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { |         if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { | ||||||
|           return null; |           return null; | ||||||
|         } |         } | ||||||
| 
 |         return createChargeFailure(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); |  | ||||||
| 
 |  | ||||||
|       }).orElse(null); |       }).orElse(null); | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|       return new SubscriptionInformation( |       return new SubscriptionInformation( | ||||||
|           new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT), |           new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT), | ||||||
|               SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())), |               SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())), | ||||||
|  | @ -458,6 +451,29 @@ public class BraintreeManager implements SubscriptionProcessorManager { | ||||||
|     }, executor); |     }, 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 |   @Override | ||||||
|   public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) { |   public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) { | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) { | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.subscriptions; | ||||||
| import com.google.common.base.Strings; | import com.google.common.base.Strings; | ||||||
| import com.google.common.collect.Lists; | import com.google.common.collect.Lists; | ||||||
| import com.stripe.StripeClient; | import com.stripe.StripeClient; | ||||||
|  | import com.stripe.exception.CardException; | ||||||
| import com.stripe.exception.StripeException; | import com.stripe.exception.StripeException; | ||||||
| import com.stripe.model.Charge; | import com.stripe.model.Charge; | ||||||
| import com.stripe.model.Customer; | import com.stripe.model.Customer; | ||||||
|  | @ -268,6 +269,18 @@ public class StripeManager implements SubscriptionProcessorManager { | ||||||
|                 .create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( |                 .create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( | ||||||
|                 customerId, lastSubscriptionCreatedAt))); |                 customerId, lastSubscriptionCreatedAt))); | ||||||
|           } catch (StripeException e) { |           } 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); |             throw new CompletionException(e); | ||||||
|           } |           } | ||||||
|         }, executor) |         }, executor) | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -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) { |   record ReceiptItem(String itemId, Instant expiration, long level) { | ||||||
| 
 | 
 | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -42,6 +42,7 @@ import java.util.function.Predicate; | ||||||
| import java.util.stream.Stream; | import java.util.stream.Stream; | ||||||
| import javax.ws.rs.client.Entity; | import javax.ws.rs.client.Entity; | ||||||
| import javax.ws.rs.core.Response; | import javax.ws.rs.core.Response; | ||||||
|  | import org.assertj.core.api.InstanceOfAssertFactories; | ||||||
| import org.glassfish.jersey.server.ServerProperties; | import org.glassfish.jersey.server.ServerProperties; | ||||||
| import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; | import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; | ||||||
| import org.junit.jupiter.api.BeforeEach; | 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.Badge; | ||||||
| import org.whispersystems.textsecuregcm.entities.BadgeSvg; | import org.whispersystems.textsecuregcm.entities.BadgeSvg; | ||||||
| import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; | import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; | ||||||
|  | import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper; | ||||||
| import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; | import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; | ||||||
| import org.whispersystems.textsecuregcm.storage.SubscriptionManager; | import org.whispersystems.textsecuregcm.storage.SubscriptionManager; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; | import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; | ||||||
|  | import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; | import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.StripeManager; | import org.whispersystems.textsecuregcm.subscriptions.StripeManager; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; | import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; | ||||||
|  | import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; | ||||||
| import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; | import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; | ||||||
| import org.whispersystems.textsecuregcm.tests.util.AuthHelper; | import org.whispersystems.textsecuregcm.tests.util.AuthHelper; | ||||||
| import org.whispersystems.textsecuregcm.util.SystemMapper; | import org.whispersystems.textsecuregcm.util.SystemMapper; | ||||||
|  | @ -112,6 +116,7 @@ class SubscriptionControllerTest { | ||||||
|       .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) |       .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) | ||||||
|       .addProvider(AuthHelper.getAuthFilter()) |       .addProvider(AuthHelper.getAuthFilter()) | ||||||
|       .addProvider(CompletionExceptionMapper.class) |       .addProvider(CompletionExceptionMapper.class) | ||||||
|  |       .addProvider(SubscriptionProcessorExceptionMapper.class) | ||||||
|       .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(Set.of( |       .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(Set.of( | ||||||
|           AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) |           AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) | ||||||
|       .setMapper(SystemMapper.jsonMapper()) |       .setMapper(SystemMapper.jsonMapper()) | ||||||
|  | @ -189,6 +194,32 @@ class SubscriptionControllerTest { | ||||||
|     assertThat(response.getStatus()).isEqualTo(422); |     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 |   @Test | ||||||
|   void createBoostReceiptNoRequest() { |   void createBoostReceiptNoRequest() { | ||||||
|     final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") |     final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") | ||||||
|  | @ -230,7 +261,7 @@ class SubscriptionControllerTest { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Test |     @Test | ||||||
|     void success() { |     void createSubscriptionSuccess() { | ||||||
|       when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) |       when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) | ||||||
|           .thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class))); |           .thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class))); | ||||||
| 
 | 
 | ||||||
|  | @ -244,6 +275,30 @@ class SubscriptionControllerTest { | ||||||
|       assertThat(response.getStatus()).isEqualTo(200); |       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 |     @Test | ||||||
|     void missingCustomerId() { |     void missingCustomerId() { | ||||||
|       final byte[] subscriberUserAndKey = new byte[32]; |       final byte[] subscriberUserAndKey = new byte[32]; | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 Chris Eager
						Chris Eager