Propagate certain subscription processor errors to client responses

This commit is contained in:
Chris Eager
2023-09-01 13:32:38 -05:00
committed by Chris Eager
parent 2d187abf13
commit b89e2e5355
9 changed files with 205 additions and 111 deletions

View File

@@ -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<WhisperServerConfiguration
new ImpossiblePhoneNumberExceptionMapper(),
new NonNormalizedPhoneNumberExceptionMapper(),
new RegistrationServiceSenderExceptionMapper(),
new SubscriptionProcessorExceptionMapper(),
new JsonMappingExceptionMapper()
).forEach(exceptionMapper -> {
environment.jersey().register(exceptionMapper);

View File

@@ -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()));
});
}

View File

@@ -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();
}
}

View File

@@ -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<ProcessorCustomer> 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<Transaction> 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<Void> cancelAllActiveSubscriptions(String customerId) {

View File

@@ -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) {
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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) {
}