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 004f407c3..79ed8ec85 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -72,7 +72,7 @@ import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; import org.whispersystems.textsecuregcm.storage.SubscriberCredentials; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.Subscriptions; import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; @@ -86,6 +86,10 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException; import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; @@ -415,19 +419,19 @@ public class SubscriptionController { subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level, currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType); return new SetSubscriptionLevelSuccessResponse(level); - } catch (SubscriptionException.InvalidLevel e) { + } catch (SubscriptionInvalidLevelException e) { throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) .entity(new SubscriptionController.SetSubscriptionLevelErrorResponse(List.of( new SubscriptionController.SetSubscriptionLevelErrorResponse.Error( SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null)))) .build()); - } catch (SubscriptionException.PaymentRequiresAction e) { + } catch (SubscriptionPaymentRequiresActionException e) { throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) .entity(new SetSubscriptionLevelErrorResponse(List.of(new SetSubscriptionLevelErrorResponse.Error( SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null)))) .build()); - } catch (SubscriptionException.InvalidArguments e) { + } catch (SubscriptionInvalidArgumentsException e) { throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST) .entity(new SetSubscriptionLevelErrorResponse(List.of(new SetSubscriptionLevelErrorResponse.Error( SetSubscriptionLevelErrorResponse.Error.Type.INVALID_ARGUMENTS, e.getMessage())))) @@ -768,7 +772,7 @@ public class SubscriptionController { UserAgentTagUtil.getPlatformTag(userAgent))) .increment(); return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build(); - } catch (SubscriptionException.ReceiptRequestedForOpenPayment e) { + } catch (SubscriptionReceiptRequestedForOpenPaymentException e) { return Response.noContent().build(); } } @@ -802,7 +806,7 @@ public class SubscriptionController { manager .setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), paymentMethodId, record.subscriptionId); - } catch (SubscriptionException.InvalidArguments e) { + } catch (SubscriptionInvalidArgumentsException e) { // Here, invalid arguments must mean that the client has made requests out of order, and needs to finish // setting up the paymentMethod first throw new ClientErrorException(Status.CONFLICT); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java index 0b1fba0cd..7331225e8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java @@ -12,8 +12,16 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import java.util.Map; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException; import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidAmountException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; public class SubscriptionExceptionMapper implements ExceptionMapper { @VisibleForTesting @@ -25,20 +33,20 @@ public class SubscriptionExceptionMapper implements ExceptionMapper Response.Status.NOT_FOUND; - case SubscriptionException.Forbidden e -> Response.Status.FORBIDDEN; - case SubscriptionException.InvalidArguments e -> Response.Status.BAD_REQUEST; - case SubscriptionException.ProcessorConflict e -> Response.Status.CONFLICT; - case SubscriptionException.PaymentRequired e -> Response.Status.PAYMENT_REQUIRED; + case SubscriptionNotFoundException e -> Response.Status.NOT_FOUND; + case SubscriptionForbiddenException e -> Response.Status.FORBIDDEN; + case SubscriptionInvalidArgumentsException e -> Response.Status.BAD_REQUEST; + case SubscriptionProcessorConflictException e -> Response.Status.CONFLICT; + case SubscriptionPaymentRequiredException e -> Response.Status.PAYMENT_REQUIRED; default -> Response.Status.INTERNAL_SERVER_ERROR; }); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriberCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriberCredentials.java index 534a59b90..813b3e5c8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriberCredentials.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriberCredentials.java @@ -15,6 +15,9 @@ import javax.annotation.Nonnull; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; public record SubscriberCredentials(@Nonnull byte[] subscriberBytes, @Nonnull byte[] subscriberUser, @@ -25,10 +28,10 @@ public record SubscriberCredentials(@Nonnull byte[] subscriberBytes, public static SubscriberCredentials process( Optional authenticatedAccount, String subscriberId, - Clock clock) throws SubscriptionException{ + Clock clock) throws SubscriptionException { Instant now = clock.instant(); if (authenticatedAccount.isPresent()) { - throw new SubscriptionException.Forbidden("must not use authenticated connection for subscriber operations"); + throw new SubscriptionForbiddenException("must not use authenticated connection for subscriber operations"); } byte[] subscriberBytes = convertSubscriberIdStringToBytes(subscriberId); byte[] subscriberUser = getUser(subscriberBytes); @@ -37,15 +40,15 @@ public record SubscriberCredentials(@Nonnull byte[] subscriberBytes, return new SubscriberCredentials(subscriberBytes, subscriberUser, subscriberKey, hmac, now); } - private static byte[] convertSubscriberIdStringToBytes(String subscriberId) throws SubscriptionException.NotFound { + private static byte[] convertSubscriberIdStringToBytes(String subscriberId) throws SubscriptionNotFoundException { try { byte[] bytes = Base64.getUrlDecoder().decode(subscriberId); if (bytes.length != 32) { - throw new SubscriptionException.NotFound(); + throw new SubscriptionNotFoundException(); } return bytes; } catch (IllegalArgumentException e) { - throw new SubscriptionException.NotFound(e); + throw new SubscriptionNotFoundException(e); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java deleted file mode 100644 index 26f95c946..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -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 { - - private @Nullable String errorDetail; - - public SubscriptionException(Exception cause) { - this(cause, null); - } - - SubscriptionException(Exception cause, String errorDetail) { - super(cause); - this.errorDetail = errorDetail; - } - - /** - * @return An error message suitable to include in a client response - */ - public Optional errorDetail() { - return Optional.ofNullable(errorDetail); - } - - public static class NotFound extends SubscriptionException { - - public NotFound() { - super(null); - } - - public NotFound(Exception cause) { - super(cause); - } - } - - public static class Forbidden extends SubscriptionException { - - public Forbidden(final String message) { - super(null, message); - } - } - - public static class InvalidArguments extends SubscriptionException { - - public InvalidArguments(final String message, final Exception cause) { - super(cause, message); - } - - public InvalidArguments(final String message) { - this(message, null); - } - } - - public static class InvalidLevel extends InvalidArguments { - - public InvalidLevel() { - super(null, null); - } - } - - public static class InvalidAmount extends InvalidArguments { - private String errorCode; - - public InvalidAmount(String errorCode) { - super(null, null); - this.errorCode = errorCode; - } - - public String getErrorCode() { - return errorCode; - } - } - - 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 PaymentProvider processor; - private final ChargeFailure chargeFailure; - - public ChargeFailurePaymentRequired(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; - } - - } - - 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 - */ - 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); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java index 2142b8e45..ab1c3dee5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java @@ -4,7 +4,6 @@ */ package org.whispersystems.textsecuregcm.storage; -import com.stripe.exception.StripeException; import java.io.IOException; import java.io.UncheckedIOException; import java.time.Instant; @@ -31,7 +30,14 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor; -import org.whispersystems.textsecuregcm.util.ExceptionUtils; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; /** @@ -65,18 +71,18 @@ public class SubscriptionManager { * Cancel a subscription with the upstream payment provider and remove the subscription from the table * * @param subscriberCredentials Subscriber credentials derived from the subscriberId - * @throws RateLimitExceededException if rate-limited - * @throws SubscriptionException.NotFound if the provided credentials are incorrect or the subscriber does not - * exist - * @throws SubscriptionException.InvalidArguments if a precondition for cancellation was not met + * @throws RateLimitExceededException if rate-limited + * @throws SubscriptionNotFoundException if the provided credentials are incorrect or the subscriber does not + * exist + * @throws SubscriptionInvalidArgumentsException if a precondition for cancellation was not met */ public void deleteSubscriber(final SubscriberCredentials subscriberCredentials) - throws SubscriptionException.NotFound, SubscriptionException.InvalidArguments, RateLimitExceededException { + throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException { final Subscriptions.GetResult getResult = subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join(); if (getResult == Subscriptions.GetResult.NOT_STORED || getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) { - throw new SubscriptionException.NotFound(); + throw new SubscriptionNotFoundException(); } // a missing customer ID is OK; it means the subscriber never started to add a payment method, so we can skip cancelling @@ -94,22 +100,22 @@ public class SubscriptionManager { * already exists, its last access time will be updated. * * @param subscriberCredentials Subscriber credentials derived from the subscriberId - * @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect + * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect */ public void updateSubscriber(final SubscriberCredentials subscriberCredentials) - throws SubscriptionException.Forbidden { + throws SubscriptionForbiddenException { final Subscriptions.GetResult getResult = subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join(); if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) { - throw new SubscriptionException.Forbidden("subscriberId mismatch"); + throw new SubscriptionForbiddenException("subscriberId mismatch"); } else if (getResult == Subscriptions.GetResult.NOT_STORED) { // create a customer and write it to ddb final Subscriptions.Record updatedRecord = subscriptions.create(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac(), subscriberCredentials.now()).join(); if (updatedRecord == null) { - throw new SubscriptionException.Forbidden("subscriberId mismatch"); + throw new SubscriptionForbiddenException("subscriberId mismatch"); } } else { // already exists so just touch access time and return @@ -119,7 +125,7 @@ public class SubscriptionManager { public Optional getSubscriptionInformation( final SubscriberCredentials subscriberCredentials) - throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.InvalidArguments, RateLimitExceededException { + throws SubscriptionForbiddenException, SubscriptionNotFoundException, RateLimitExceededException { final Subscriptions.Record record = getSubscriber(subscriberCredentials); if (record.subscriptionId == null) { return Optional.empty(); @@ -132,17 +138,17 @@ public class SubscriptionManager { * Get the subscriber record * * @param subscriberCredentials Subscriber credentials derived from the subscriberId - * @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect - * @throws SubscriptionException.NotFound if the subscriber did not exist + * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect + * @throws SubscriptionNotFoundException if the subscriber did not exist */ public Subscriptions.Record getSubscriber(final SubscriberCredentials subscriberCredentials) - throws SubscriptionException.Forbidden, SubscriptionException.NotFound { + throws SubscriptionForbiddenException, SubscriptionNotFoundException { final Subscriptions.GetResult getResult = subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join(); if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) { - throw new SubscriptionException.Forbidden("subscriberId mismatch"); + throw new SubscriptionForbiddenException("subscriberId mismatch"); } else if (getResult == Subscriptions.GetResult.NOT_STORED) { - throw new SubscriptionException.NotFound(); + throw new SubscriptionNotFoundException(); } else { return getResult.record; } @@ -161,34 +167,31 @@ public class SubscriptionManager { * @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} * and returns the expiration time of the receipt * @return the requested ZK receipt credential - * @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect - * @throws SubscriptionException.NotFound if the subscriber did not exist or did not have a - * subscription attached - * @throws SubscriptionException.InvalidArguments if the receipt credential request failed verification - * @throws SubscriptionException.PaymentRequired if the subscription is in a state does not grant the - * user an entitlement - * @throws SubscriptionException.ChargeFailurePaymentRequired if the subscription is in a state does not grant the - * user an entitlement because a charge failed to go - * through - * @throws SubscriptionException.ReceiptRequestedForOpenPayment if a receipt was requested while a payment transaction - * was still open - * @throws RateLimitExceededException if rate-limited + * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect + * @throws SubscriptionNotFoundException if the subscriber did not exist or did not have a + * subscription attached + * @throws SubscriptionInvalidArgumentsException if the receipt credential request failed verification + * @throws SubscriptionPaymentRequiredException if the subscription is in a state does not grant the + * user an entitlement + * @throws SubscriptionReceiptRequestedForOpenPaymentException if a receipt was requested while a payment transaction + * was still open + * @throws RateLimitExceededException if rate-limited */ public ReceiptResult createReceiptCredentials( final SubscriberCredentials subscriberCredentials, final SubscriptionController.GetReceiptCredentialsRequest request, final Function expiration) - throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.InvalidArguments, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, RateLimitExceededException, SubscriptionException.ReceiptRequestedForOpenPayment { + throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException, SubscriptionReceiptRequestedForOpenPaymentException { final Subscriptions.Record record = getSubscriber(subscriberCredentials); if (record.subscriptionId == null) { - throw new SubscriptionException.NotFound(); + throw new SubscriptionNotFoundException(); } ReceiptCredentialRequest receiptCredentialRequest; try { receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest()); } catch (InvalidInputException e) { - throw new SubscriptionException.InvalidArguments("invalid receipt credential request", e); + throw new SubscriptionInvalidArgumentsException("invalid receipt credential request", e); } final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor(); @@ -204,7 +207,7 @@ public class SubscriptionManager { expiration.apply(receipt).getEpochSecond(), receipt.level()); } catch (VerificationFailedException e) { - throw new SubscriptionException.InvalidArguments("receipt credential request failed verification", e); + throw new SubscriptionInvalidArgumentsException("receipt credential request failed verification", e); } return new ReceiptResult(receiptCredentialResponse, receipt, processor); } @@ -228,18 +231,18 @@ public class SubscriptionManager { * @param The return type of the paymentSetupFunction, which should be used by a client * to configure the newly created payment method * @return The return value of the paymentSetupFunction - * @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect - * @throws SubscriptionException.NotFound if the subscriber did not exist or did not have a subscription - * attached - * @throws SubscriptionException.ProcessorConflict if the new payment processor the existing processor associated with - * the subscriberId + * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect + * @throws SubscriptionNotFoundException if the subscriber did not exist or did not have a subscription + * attached + * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with + * the subscriberId */ public R addPaymentMethodToCustomer( final SubscriberCredentials subscriberCredentials, final T subscriptionPaymentProcessor, final ClientPlatform clientPlatform, final BiFunction paymentSetupFunction) - throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.ProcessorConflict { + throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionProcessorConflictException { Subscriptions.Record record = this.getSubscriber(subscriberCredentials); if (record.getProcessorCustomer().isEmpty()) { @@ -253,7 +256,7 @@ public class SubscriptionManager { .orElseThrow(() -> new UncheckedIOException(new IOException("processor must now exist"))); if (processorCustomer.processor() != subscriptionPaymentProcessor.getProvider()) { - throw new SubscriptionException.ProcessorConflict("existing processor does not match"); + throw new SubscriptionProcessorConflictException("existing processor does not match"); } return paymentSetupFunction.apply(subscriptionPaymentProcessor, processorCustomer.customerId()); } @@ -288,13 +291,13 @@ public class SubscriptionManager { * @param subscriptionTemplateId Specifies the product associated with the provided level within the payment * processor * @param transitionValidator A function that checks if the level update is valid - * @throws SubscriptionException.InvalidArguments if the transitionValidator failed for the level transition, or the - * subscription could not be created because the payment provider - * requires additional action, or there was a failure because an - * idempotency key was reused on a * modified request - * @throws SubscriptionException.ProcessorConflict if the new payment processor the existing processor associated - * with the subscriber - * @throws SubscriptionException.ProcessorException if there was no payment method on the customer + * @throws SubscriptionInvalidArgumentsException if the transitionValidator failed for the level transition, or the + * subscription could not be created because the payment provider + * requires additional action, or there was a failure because an + * idempotency key was reused on a * modified request + * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with + * the subscriber + * @throws SubscriptionProcessorException if there was no payment method on the customer */ public void updateSubscriptionLevelForCustomer( final SubscriberCredentials subscriberCredentials, @@ -305,7 +308,7 @@ public class SubscriptionManager { final String idempotencyKey, final String subscriptionTemplateId, final LevelTransitionValidator transitionValidator) - throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { + throws SubscriptionInvalidArgumentsException, SubscriptionProcessorConflictException, SubscriptionProcessorException { if (record.subscriptionId != null) { // we already have a subscription in our records so let's check the level and currency, @@ -319,7 +322,7 @@ public class SubscriptionManager { return; } if (!transitionValidator.isTransitionValid(existingLevelAndCurrency.level(), level)) { - throw new SubscriptionException.InvalidLevel(); + throw new SubscriptionInvalidLevelException(); } final CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updatedSubscriptionId = processor.updateSubscription(subscription, subscriptionTemplateId, level, idempotencyKey); @@ -353,20 +356,20 @@ public class SubscriptionManager { * @param purchaseToken The client provided purchaseToken that represents a purchased subscription in the * play store * @return the subscription level for the accepted subscription - * @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect - * @throws SubscriptionException.NotFound if the subscriber did not exist or did not have a subscription - * attached - * @throws SubscriptionException.ProcessorConflict if the new payment processor the existing processor associated with - * the subscriberId - * @throws SubscriptionException.PaymentRequired if the subscription is not in a state that grants the user an - * entitlement - * @throws RateLimitExceededException if rate-limited + * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect + * @throws SubscriptionNotFoundException if the subscriber did not exist or did not have a subscription + * attached + * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with + * the subscriberId + * @throws SubscriptionPaymentRequiredException if the subscription is not in a state that grants the user an + * entitlement + * @throws RateLimitExceededException if rate-limited */ public long updatePlayBillingPurchaseToken( final SubscriberCredentials subscriberCredentials, final GooglePlayBillingManager googlePlayBillingManager, final String purchaseToken) - throws SubscriptionException.ProcessorConflict, SubscriptionException.Forbidden, SubscriptionException.NotFound, RateLimitExceededException, SubscriptionException.PaymentRequired { + throws SubscriptionProcessorConflictException, SubscriptionForbiddenException, SubscriptionNotFoundException, RateLimitExceededException, SubscriptionPaymentRequiredException { // For IAP providers, the subscriptionId and the customerId are both just the purchaseToken. Changes to the // subscription always just result in a new purchaseToken @@ -377,7 +380,7 @@ public class SubscriptionManager { // Check the record for an existing subscription if (record.processorCustomer != null && record.processorCustomer.processor() != PaymentProvider.GOOGLE_PLAY_BILLING) { - throw new SubscriptionException.ProcessorConflict("existing processor does not match"); + throw new SubscriptionProcessorConflictException("existing processor does not match"); } // If we're replacing an existing purchaseToken, cancel it first @@ -406,26 +409,26 @@ public class SubscriptionManager { * @param originalTransactionId The client provided originalTransactionId that represents a purchased subscription in * the app store * @return the subscription level for the accepted subscription - * @throws SubscriptionException.Forbidden if the subscriber credentials are incorrect - * @throws SubscriptionException.NotFound if the originalTransactionId does not exist - * @throws SubscriptionException.ProcessorConflict if the new payment processor the existing processor associated with - * the subscriber - * @throws SubscriptionException.InvalidArguments if the originalTransactionId is malformed or does not represent a - * valid subscription - * @throws SubscriptionException.PaymentRequired if the subscription is not in a state that grants the user an - * entitlement - * @throws RateLimitExceededException if rate-limited + * @throws SubscriptionForbiddenException if the subscriber credentials are incorrect + * @throws SubscriptionNotFoundException if the originalTransactionId does not exist + * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with + * the subscriber + * @throws SubscriptionInvalidArgumentsException if the originalTransactionId is malformed or does not represent a + * valid subscription + * @throws SubscriptionPaymentRequiredException if the subscription is not in a state that grants the user an + * entitlement + * @throws RateLimitExceededException if rate-limited */ public long updateAppStoreTransactionId( final SubscriberCredentials subscriberCredentials, final AppleAppStoreManager appleAppStoreManager, final String originalTransactionId) - throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.ProcessorConflict, SubscriptionException.InvalidArguments, SubscriptionException.PaymentRequired, RateLimitExceededException { + throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionProcessorConflictException, SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException { final Subscriptions.Record record = getSubscriber(subscriberCredentials); if (record.processorCustomer != null && record.processorCustomer.processor() != PaymentProvider.APPLE_APP_STORE) { - throw new SubscriptionException.ProcessorConflict("existing processor does not match"); + throw new SubscriptionProcessorConflictException("existing processor does not match"); } // For IAP providers, the subscriptionId and the customerId are both just the identifier for the subscription in diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java index 447548e0c..8d9b04076 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java @@ -41,7 +41,6 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.ResilienceUtil; /** @@ -114,16 +113,16 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { * @param originalTransactionId The originalTransactionId associated with the subscription * @return the subscription level of the valid transaction. * @throws RateLimitExceededException If rate-limited - * @throws SubscriptionException.NotFound If the provided originalTransactionId was not found - * @throws SubscriptionException.PaymentRequired If the originalTransactionId exists but is in a state that does not + * @throws SubscriptionNotFoundException If the provided originalTransactionId was not found + * @throws SubscriptionPaymentRequiredException If the originalTransactionId exists but is in a state that does not * grant the user an entitlement - * @throws SubscriptionException.InvalidArguments If the transaction is valid but does not contain a subscription + * @throws SubscriptionInvalidArgumentsException If the transaction is valid but does not contain a subscription */ public Long validateTransaction(final String originalTransactionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired { - final DecodedTransaction tx = lookup(originalTransactionId); + throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { + final DecodedTransaction tx = lookupAndValidateTransaction(originalTransactionId); if (!isSubscriptionActive(tx)) { - throw new SubscriptionException.PaymentRequired(); + throw new SubscriptionPaymentRequiredException(); } return getLevel(tx); } @@ -137,25 +136,29 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { * this method. * * @param originalTransactionId The originalTransactionId associated with the subscription - * @throws SubscriptionException.NotFound If the provided originalTransactionId was not found - * @throws SubscriptionException.InvalidArguments If the transaction is valid but does not contain a subscription, or + * @throws RateLimitExceededException If rate-limited + * @throws SubscriptionInvalidArgumentsException If the transaction is valid but does not contain a subscription, or * the transaction has not already been cancelled with storekit */ @Override public void cancelAllActiveSubscriptions(String originalTransactionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound { - final DecodedTransaction tx = lookup(originalTransactionId); - if (tx.signedTransaction.getStatus() != Status.EXPIRED && - tx.signedTransaction.getStatus() != Status.REVOKED && - tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) { - throw new SubscriptionException.InvalidArguments("must cancel subscription with storekit before deleting"); + throws SubscriptionInvalidArgumentsException, RateLimitExceededException { + try { + final DecodedTransaction tx = lookup(originalTransactionId); + if (tx.signedTransaction.getStatus() != Status.EXPIRED && + tx.signedTransaction.getStatus() != Status.REVOKED && + tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) { + throw new SubscriptionInvalidArgumentsException("must cancel subscription with storekit before deleting"); + } + } catch (SubscriptionNotFoundException _) { + // If the subscription is not found there is no need to do anything, so we can squash it } - // The subscription will not auto-renew, so we can stop tracking it + // The subscription will not auto-renew, so we can stop tracking it } @Override public SubscriptionInformation getSubscriptionInformation(final String originalTransactionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { final DecodedTransaction tx = lookup(originalTransactionId); final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) { case ACTIVE -> SubscriptionStatus.ACTIVE; @@ -181,10 +184,10 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { @Override public ReceiptItem getReceiptItem(String originalTransactionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired { + throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { final DecodedTransaction tx = lookup(originalTransactionId); if (!isSubscriptionActive(tx)) { - throw new SubscriptionException.PaymentRequired(); + throw new SubscriptionPaymentRequiredException(); } // A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is @@ -198,11 +201,21 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { } private DecodedTransaction lookup(final String originalTransactionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { + try { + return lookupAndValidateTransaction(originalTransactionId); + } catch (SubscriptionInvalidArgumentsException e) { + // Shouldn't happen because we previously validated this transactionId before storing it + throw new UncheckedIOException(new IOException(e)); + } + } + + private DecodedTransaction lookupAndValidateTransaction(final String originalTransactionId) + throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException { final StatusResponse statuses = getAllSubscriptions(originalTransactionId); final SubscriptionGroupIdentifierItem item = statuses.getData().stream() .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst() - .orElseThrow(() -> new SubscriptionException.InvalidArguments("transaction did not contain a backup subscription", null)); + .orElseThrow(() -> new SubscriptionInvalidArgumentsException("transaction did not contain a backup subscription", null)); final List txs = item.getLastTransactions().stream() .map(this::decode) @@ -210,7 +223,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { .toList(); if (txs.isEmpty()) { - throw new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null); + throw new SubscriptionInvalidArgumentsException("transactionId did not include a paid subscription", null); } if (txs.size() > 1) { @@ -222,13 +235,13 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { // Get All Subscriptions only requires that the transaction be some transaction associated with the // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the // SubscriptionManager. - throw new SubscriptionException.InvalidArguments("transactionId was not the transaction's originalTransactionId", null); + throw new SubscriptionInvalidArgumentsException("transactionId was not the transaction's originalTransactionId", null); } return txs.getFirst(); } private StatusResponse getAllSubscriptions(final String originalTransactionId) - throws SubscriptionException.NotFound, SubscriptionException.InvalidArguments, RateLimitExceededException { + throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException { try { return retry.executeCallable(() -> { try { @@ -236,9 +249,9 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { } catch (final APIException e) { Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment(); throw switch (e.getApiError()) { - case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionException.NotFound(); + case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionNotFoundException(); case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null); - case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage()); + case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionInvalidArgumentsException(e.getApiErrorMessage()); default -> e; }; } catch (final IOException e) { @@ -246,7 +259,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { throw e; } }); - } catch (SubscriptionException.NotFound | SubscriptionException.InvalidArguments | RateLimitExceededException e) { + } catch (SubscriptionNotFoundException | SubscriptionInvalidArgumentsException | RateLimitExceededException e) { throw e; } catch (IOException e) { throw new UncheckedIOException(e); @@ -290,11 +303,11 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount)); } - private long getLevel(final DecodedTransaction tx) throws SubscriptionException.InvalidArguments { + private long getLevel(final DecodedTransaction tx) { final Long level = productIdToLevel.get(tx.transaction.getProductId()); if (level == null) { - throw new SubscriptionException.InvalidArguments( - "Transaction for unknown productId " + tx.transaction.getProductId()); + throw new UncheckedIOException(new IOException( + "Transaction for unknown productId " + tx.transaction.getProductId())); } return level; } 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 a114ef894..094f807db 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -38,7 +38,6 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -import java.util.concurrent.ScheduledExecutorService; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,7 +45,6 @@ import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExecutorUtil; import org.whispersystems.textsecuregcm.util.GoogleApiUtil; @@ -207,7 +205,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess return switch (unsuccessfulTx.getProcessorResponseCode()) { case GENERIC_DECLINED_PROCESSOR_CODE, PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE -> CompletableFuture.failedFuture( - new SubscriptionException.ProcessorException(getProvider(), createChargeFailure(unsuccessfulTx))); + new SubscriptionProcessorException(getProvider(), createChargeFailure(unsuccessfulTx))); default -> { logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode()); @@ -362,11 +360,11 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess @Override public SubscriptionId createSubscription(String customerId, String planId, long level, long lastSubscriptionCreatedAt) - throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { + throws SubscriptionProcessorConflictException, SubscriptionProcessorException { final com.braintreegateway.PaymentMethod paymentMethod = getDefaultPaymentMethod(customerId); if (paymentMethod == null) { - throw new SubscriptionException.ProcessorConflict(); + throw new SubscriptionProcessorConflictException(); } final Optional maybeExistingSubscription = paymentMethod.getSubscriptions().stream() @@ -400,7 +398,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess throw Optional .ofNullable(result.getTarget()) .flatMap(subscription -> subscription.getTransactions().stream().findFirst()) - .map(transaction -> new SubscriptionException.ProcessorException(getProvider(), + .map(transaction -> new SubscriptionProcessorException(getProvider(), createChargeFailure(transaction))) .orElseThrow(() -> new BraintreeException(result.getMessage())); } @@ -415,7 +413,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess @Override public CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updateSubscription(Object subscriptionObj, String planId, long level, - String idempotencyKey) throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { + String idempotencyKey) throws SubscriptionProcessorConflictException, SubscriptionProcessorException { if (!(subscriptionObj instanceof final Subscription subscription)) { throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); @@ -427,7 +425,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess endSubscription(subscription); final Transaction transaction = getLatestTransactionForSubscription(subscription) - .orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict())); + .orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionProcessorConflictException())); final Customer customer = transaction.getCustomer(); @@ -579,17 +577,17 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess @Override public ReceiptItem getReceiptItem(String subscriptionId) - throws SubscriptionException.ReceiptRequestedForOpenPayment, SubscriptionException.ChargeFailurePaymentRequired { + throws SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionChargeFailurePaymentRequiredException { final Subscription subscription = getSubscription(getSubscription(subscriptionId)); final Transaction transaction = getLatestTransactionForSubscription(subscription) - .orElseThrow(SubscriptionException.ReceiptRequestedForOpenPayment::new); + .orElseThrow(SubscriptionReceiptRequestedForOpenPaymentException::new); if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { final SubscriptionStatus subscriptionStatus = getSubscriptionStatus(subscription.getStatus(), true); if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals( SubscriptionStatus.PAST_DUE)) { - throw new SubscriptionException.ReceiptRequestedForOpenPayment(); + throw new SubscriptionReceiptRequestedForOpenPaymentException(); } - throw new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction)); + throw new SubscriptionChargeFailurePaymentRequiredException(getProvider(), createChargeFailure(transaction)); } final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java index 7173e1ef7..a85e90502 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java @@ -6,9 +6,7 @@ package org.whispersystems.textsecuregcm.subscriptions; import java.util.Set; -import java.util.concurrent.CompletableFuture; import javax.annotation.Nullable; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; /** @@ -42,11 +40,11 @@ public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionP * @param paymentMethodToken a processor-specific token previously acquired at * {@link #createPaymentMethodSetupToken} * @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update - * @throws SubscriptionException.InvalidArguments If the paymentMethodToken is invalid or the payment method has not + * @throws SubscriptionInvalidArgumentsException If the paymentMethodToken is invalid or the payment method has not * finished being set up */ void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, - @Nullable String currentSubscriptionId) throws SubscriptionException.InvalidArguments; + @Nullable String currentSubscriptionId) throws SubscriptionInvalidArgumentsException; Object getSubscription(String subscriptionId); @@ -58,14 +56,14 @@ public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionP * @param level The level of the subscription * @param lastSubscriptionCreatedAt The timestamp of the last successfully created subscription * @return A subscription identifier - * @throws SubscriptionException.ProcessorException If there was a failure processing the charge - * @throws SubscriptionException.InvalidArguments If there was a failure because an idempotency key was reused on a + * @throws SubscriptionProcessorException If there was a failure processing the charge + * @throws SubscriptionInvalidArgumentsException If there was a failure because an idempotency key was reused on a * modified request, or if the payment requires additional steps * before charging - * @throws SubscriptionException.ProcessorConflict If there was no payment method on the customer + * @throws SubscriptionProcessorConflictException If there was no payment method on the customer */ SubscriptionId createSubscription(String customerId, String templateId, long level, long lastSubscriptionCreatedAt) - throws SubscriptionException.ProcessorException, SubscriptionException.InvalidArguments, SubscriptionException.ProcessorConflict; + throws SubscriptionProcessorException, SubscriptionInvalidArgumentsException, SubscriptionProcessorConflictException; /** * Update an existing subscription on a customer @@ -75,14 +73,14 @@ public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionP * @param level The target level of the subscription * @param idempotencyKey An idempotency key to prevent retries of successful requests * @return A subscription identifier - * @throws SubscriptionException.ProcessorException If there was a failure processing the charge - * @throws SubscriptionException.InvalidArguments If there was a failure because an idempotency key was reused on a + * @throws SubscriptionProcessorException If there was a failure processing the charge + * @throws SubscriptionInvalidArgumentsException If there was a failure because an idempotency key was reused on a * modified request, or if the payment requires additional steps * before charging - * @throws SubscriptionException.ProcessorConflict If there was no payment method on the customer + * @throws SubscriptionProcessorConflictException If there was no payment method on the customer */ SubscriptionId updateSubscription(Object subscription, String templateId, long level, String idempotencyKey) - throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException, SubscriptionException.ProcessorConflict; + throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException, SubscriptionProcessorConflictException; /** * @param subscription diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java index 2d470c4b8..04a598238 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java @@ -46,7 +46,6 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; /** * Manages subscriptions made with the Play Billing API @@ -134,7 +133,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { * @return A stage that completes when the purchase has been successfully acknowledged */ public void acknowledgePurchase() - throws RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { if (!requiresAck) { // We've already acknowledged this purchase on a previous attempt, nothing to do return; @@ -155,12 +154,12 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { * @param purchaseToken The play store billing purchaseToken that represents a subscription purchase * @return A {@link ValidatedToken} that can be acknowledged * @throws RateLimitExceededException If rate-limited by play-billing - * @throws SubscriptionException.NotFound If the provided purchaseToken was not found in play-billing - * @throws SubscriptionException.PaymentRequired If the purchaseToken exists but is in a state that does not grant the + * @throws SubscriptionNotFoundException If the provided purchaseToken was not found in play-billing + * @throws SubscriptionPaymentRequiredException If the purchaseToken exists but is in a state that does not grant the * user an entitlement */ public ValidatedToken validateToken(String purchaseToken) - throws RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired { + throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken); final SubscriptionState state = SubscriptionState .fromString(subscription.getSubscriptionState()) @@ -175,7 +174,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { if (state != SubscriptionState.ACTIVE && state != SubscriptionState.IN_GRACE_PERIOD && state != SubscriptionState.CANCELED) { - throw new SubscriptionException.PaymentRequired( + throw new SubscriptionPaymentRequiredException( "Cannot acknowledge purchase for subscription in state " + subscription.getSubscriptionState()); } @@ -221,14 +220,14 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { executeTokenOperation(pub -> pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken)); - } catch (SubscriptionException.NotFound e) { - // If the subscription is not found, no need to do anything so we can squash it + } catch (SubscriptionNotFoundException e) { + // If the subscription is not found there is no need to do anything, so we can squash it } } @Override public SubscriptionInformation getSubscriptionInformation(final String purchaseToken) - throws RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken); final SubscriptionPrice price = getSubscriptionPrice(subscription); @@ -300,7 +299,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { @Override public ReceiptItem getReceiptItem(String purchaseToken) - throws RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired { + throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken); final AcknowledgementState acknowledgementState = AcknowledgementState .fromString(subscription.getAcknowledgementState()) @@ -321,7 +320,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { // We don't need to check any state at this point, just whether the subscription is currently valid. If the // subscription is in a grace period, the expiration time will be dynamically extended, see // https://developer.android.com/google/play/billing/lifecycle/subscriptions#grace-period - throw new SubscriptionException.PaymentRequired(); + throw new SubscriptionPaymentRequiredException(); } return new ReceiptItem( @@ -345,13 +344,13 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { * @return A stage that completes with the result of the API call */ private R executeTokenOperation(final ApiCall apiCall) - throws RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { try { return apiCall.req(androidPublisher).execute(); } catch (HttpResponseException e) { if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode() || e.getStatusCode() == Response.Status.GONE.getStatusCode()) { - throw new SubscriptionException.NotFound(); + throw new SubscriptionNotFoundException(); } if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) { throw new RateLimitExceededException(null); @@ -368,7 +367,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { } private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken) - throws RateLimitExceededException, SubscriptionException.NotFound { + throws RateLimitExceededException, SubscriptionNotFoundException { return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken)); } 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 2d9fba314..e509f037b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -65,7 +65,6 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; import java.util.function.Consumer; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -76,7 +75,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.WhisperServerVersion; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.Conversions; import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExecutorUtil; @@ -175,7 +173,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor @Override public void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId, - @Nullable String currentSubscriptionId) throws SubscriptionException.InvalidArguments { + @Nullable String currentSubscriptionId) throws SubscriptionInvalidArgumentsException { CustomerUpdateParams params = CustomerUpdateParams.builder() .setInvoiceSettings(InvoiceSettings.builder() .setDefaultPaymentMethod(paymentMethodId) @@ -185,7 +183,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor stripeClient.customers().update(customerId, params, commonOptions()); } catch (InvalidRequestException e) { // Could happen if the paymentMethodId was bunk or the client didn't actually finish setting it up - throw new SubscriptionException.InvalidArguments(e.getMessage()); + throw new SubscriptionInvalidArgumentsException(e.getMessage()); } catch (StripeException e) { throw new UncheckedIOException(new IOException(e)); } @@ -209,7 +207,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor } /** - * Creates a payment intent. May throw a {@link SubscriptionException.InvalidAmount} if stripe rejects the + * Creates a payment intent. May throw a {@link SubscriptionInvalidAmountException} if stripe rejects the * attempt if the amount is too large or too small */ public CompletableFuture createPaymentIntent(final String currency, @@ -234,7 +232,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor final String errorCode = StringUtils.lowerCase(e.getCode(), Locale.ROOT); switch (errorCode) { case "amount_too_small","amount_too_large" -> - throw ExceptionUtils.wrap(new SubscriptionException.InvalidAmount(errorCode)); + throw ExceptionUtils.wrap(new SubscriptionInvalidAmountException(errorCode)); default -> throw new CompletionException(e); } } @@ -286,7 +284,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor @Override public SubscriptionId createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) - throws SubscriptionException.ProcessorException, SubscriptionException.InvalidArguments { + throws SubscriptionProcessorException, SubscriptionInvalidArgumentsException { // this relies on Stripe's idempotency key to avoid creating more than one subscription if the client // retries this request SubscriptionCreateParams params = SubscriptionCreateParams.builder() @@ -308,12 +306,12 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor commonOptions(generateIdempotencyKeyForCreateSubscription(customerId, lastSubscriptionCreatedAt))); return new SubscriptionId(subscription.getId()); } catch (IdempotencyException e) { - throw new SubscriptionException.InvalidArguments(e.getStripeError().getMessage()); + throw new SubscriptionInvalidArgumentsException(e.getStripeError().getMessage()); } catch (CardException e) { - throw new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e)); + throw new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e)); } catch (StripeException e) { if ("subscription_payment_intent_requires_action".equals(e.getCode())) { - throw new SubscriptionException.PaymentRequiresAction(); + throw new SubscriptionPaymentRequiresActionException(); } throw new UncheckedIOException(new IOException(e)); } @@ -321,7 +319,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor @Override public SubscriptionId updateSubscription(Object subscriptionObj, String priceId, long level, String idempotencyKey) - throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException { + throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException { final Subscription subscription = getSubscription(subscriptionObj); @@ -360,9 +358,9 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor commonOptions(generateIdempotencyKeyForSubscriptionUpdate(subscription.getCustomer(), idempotencyKey))); return new SubscriptionId(subscription1.getId()); } catch (IdempotencyException e) { - throw new SubscriptionException.InvalidArguments(e.getStripeError().getMessage()); + throw new SubscriptionInvalidArgumentsException(e.getStripeError().getMessage()); } catch (CardException e) { - throw new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e)); + throw new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e)); } catch (StripeException e) { throw new UncheckedIOException(new IOException(e)); } @@ -607,27 +605,27 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor @Override public ReceiptItem getReceiptItem(String subscriptionId) - throws SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment { + throws SubscriptionPaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException { final Invoice invoice = getSubscription(getSubscription(subscriptionId)).getLatestInvoiceObject(); return convertInvoiceToReceipt(invoice, subscriptionId); } private ReceiptItem convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) - throws SubscriptionException.ReceiptRequestedForOpenPayment, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired { + throws SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionPaymentRequiredException { if (latestSubscriptionInvoice == null) { - throw new SubscriptionException.ReceiptRequestedForOpenPayment(); + throw new SubscriptionReceiptRequestedForOpenPaymentException(); } if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) { - throw new SubscriptionException.ReceiptRequestedForOpenPayment(); + throw new SubscriptionReceiptRequestedForOpenPaymentException(); } if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) { final Charge charge = latestSubscriptionInvoice.getChargeObject(); if (charge != null && (charge.getFailureCode() != null || charge.getFailureMessage() != null)) { // If the charge object has a failure reason we can present to the user, create a detailed exception - throw new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge)); + throw new SubscriptionChargeFailurePaymentRequiredException(getProvider(), createChargeFailure(charge)); } else { // Otherwise, return a generic payment required error - throw new SubscriptionException.PaymentRequired(); + throw new SubscriptionPaymentRequiredException(); } } @@ -689,12 +687,12 @@ 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 ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict()); + throw ExceptionUtils.wrap(new SubscriptionProcessorConflictException()); } return setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit(); } catch (StripeException e) { if (e.getStatusCode() == 404) { - throw ExceptionUtils.wrap(new SubscriptionException.NotFound()); + throw ExceptionUtils.wrap(new SubscriptionNotFoundException()); } logger.error("unexpected error from Stripe when retrieving setupIntent {}", setupIntentId, e); throw ExceptionUtils.wrap(e); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionChargeFailurePaymentRequiredException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionChargeFailurePaymentRequiredException.java new file mode 100644 index 000000000..e310e7007 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionChargeFailurePaymentRequiredException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionChargeFailurePaymentRequiredException extends SubscriptionPaymentRequiredException { + + private final PaymentProvider processor; + private final ChargeFailure chargeFailure; + + public SubscriptionChargeFailurePaymentRequiredException(final PaymentProvider processor, + final ChargeFailure chargeFailure) { + super(); + this.processor = processor; + this.chargeFailure = chargeFailure; + } + + public PaymentProvider getProcessor() { + return processor; + } + + public ChargeFailure getChargeFailure() { + return chargeFailure; + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionException.java new file mode 100644 index 000000000..349b8115f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +import java.util.Optional; +import javax.annotation.Nullable; + +public class SubscriptionException extends Exception { + + private @Nullable String errorDetail; + + public SubscriptionException(Exception cause) { + this(cause, null); + } + + SubscriptionException(Exception cause, String errorDetail) { + super(cause); + this.errorDetail = errorDetail; + } + + /** + * @return An error message suitable to include in a client response + */ + public Optional errorDetail() { + return Optional.ofNullable(errorDetail); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionForbiddenException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionForbiddenException.java new file mode 100644 index 000000000..1a044b9d5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionForbiddenException.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionForbiddenException extends SubscriptionException { + + public SubscriptionForbiddenException(final String message) { + super(null, message); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidAmountException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidAmountException.java new file mode 100644 index 000000000..548d9e931 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidAmountException.java @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionInvalidAmountException extends SubscriptionInvalidArgumentsException { + + private String errorCode; + + public SubscriptionInvalidAmountException(String errorCode) { + super(null, null); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidArgumentsException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidArgumentsException.java new file mode 100644 index 000000000..96007b652 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidArgumentsException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionInvalidArgumentsException extends SubscriptionException { + + public SubscriptionInvalidArgumentsException(final String message, final Exception cause) { + super(cause, message); + } + + public SubscriptionInvalidArgumentsException(final String message) { + this(message, null); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidLevelException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidLevelException.java new file mode 100644 index 000000000..d613a63df --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidLevelException.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionInvalidLevelException extends SubscriptionInvalidArgumentsException { + + public SubscriptionInvalidLevelException() { + super(null, null); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionNotFoundException.java new file mode 100644 index 000000000..2640eac8f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionNotFoundException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionNotFoundException extends SubscriptionException { + + public SubscriptionNotFoundException() { + super(null); + } + + public SubscriptionNotFoundException(Exception cause) { + super(cause); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java index 12c44e6d5..452cfd202 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java @@ -6,9 +6,6 @@ package org.whispersystems.textsecuregcm.subscriptions; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; - -import java.util.concurrent.CompletableFuture; public interface SubscriptionPaymentProcessor { @@ -29,22 +26,16 @@ public interface SubscriptionPaymentProcessor { * * @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription * @return A {@link ReceiptItem} if the subscription is valid - * - * @throws RateLimitExceededException If rate-limited - * @throws SubscriptionException.NotFound If the provided subscriptionId could not be found with - * the provider - * @throws SubscriptionException.InvalidArguments If the subscriptionId locates a subscription that - * cannot be used to generate a receipt - * @throws SubscriptionException.PaymentRequired If the subscription is in a state does not grant the - * user an entitlement - * @throws SubscriptionException.ChargeFailurePaymentRequired If the subscription is in a state does not grant the - * user an entitlement because a charge failed to go - * through - * @throws SubscriptionException.ReceiptRequestedForOpenPayment If a receipt was requested while a payment transaction - * was still open + * @throws RateLimitExceededException If rate-limited + * @throws SubscriptionNotFoundException If the provided subscriptionId could not be found with + * the provider + * @throws SubscriptionPaymentRequiredException If the subscription is in a state does not grant the + * user an entitlement + * @throws SubscriptionReceiptRequestedForOpenPaymentException If a receipt was requested while a payment transaction + * was still open */ ReceiptItem getReceiptItem(String subscriptionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment; + throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionChargeFailurePaymentRequiredException, SubscriptionPaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException; /** * Cancel all active subscriptions for this key within the payment processor. @@ -52,22 +43,19 @@ public interface SubscriptionPaymentProcessor { * @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the * subscriptions table * @throws RateLimitExceededException If rate-limited - * @throws SubscriptionException.NotFound If the provided key was not found with the provider - * @throws SubscriptionException.InvalidArguments If a precondition for cancellation was not met + * @throws SubscriptionInvalidArgumentsException If a precondition for cancellation was not met */ void cancelAllActiveSubscriptions(String key) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound; + throws SubscriptionInvalidArgumentsException, RateLimitExceededException; /** * Retrieve subscription information from the processor * * @param subscriptionId The identifier with the processor to retrieve information for * @return {@link SubscriptionInformation} from the provider - * @throws RateLimitExceededException If rate-limited - * @throws SubscriptionException.NotFound If the provided key was not found with the provider - * @throws SubscriptionException.InvalidArguments If the subscription exists on the provider but does not represent a - * valid subscription + * @throws RateLimitExceededException If rate-limited + * @throws SubscriptionNotFoundException If the provided key was not found with the provider */ SubscriptionInformation getSubscriptionInformation(final String subscriptionId) - throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound; + throws RateLimitExceededException, SubscriptionNotFoundException; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiredException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiredException.java new file mode 100644 index 000000000..8691b51f8 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiredException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionPaymentRequiredException extends SubscriptionException { + + public SubscriptionPaymentRequiredException() { + super(null, null); + } + + public SubscriptionPaymentRequiredException(String message) { + super(null, message); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiresActionException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiresActionException.java new file mode 100644 index 000000000..ca74ea339 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiresActionException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionPaymentRequiresActionException extends SubscriptionInvalidArgumentsException { + + public SubscriptionPaymentRequiresActionException(String message) { + super(message, null); + } + + public SubscriptionPaymentRequiresActionException() { + super(null, null); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorConflictException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorConflictException.java new file mode 100644 index 000000000..d33aa665b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorConflictException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionProcessorConflictException extends SubscriptionException { + + public SubscriptionProcessorConflictException() { + super(null, null); + } + + public SubscriptionProcessorConflictException(final String message) { + super(null, message); + } +} 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..b276233e2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +public class SubscriptionProcessorException extends SubscriptionException { + + private final PaymentProvider processor; + private final ChargeFailure chargeFailure; + + public SubscriptionProcessorException(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; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionReceiptRequestedForOpenPaymentException.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionReceiptRequestedForOpenPaymentException.java new file mode 100644 index 000000000..65845e8c8 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionReceiptRequestedForOpenPaymentException.java @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +/** + * Attempted to retrieve a receipt for a subscription that hasn't yet been charged or the invoice is in the open state + */ +public class SubscriptionReceiptRequestedForOpenPaymentException extends SubscriptionException { + + public SubscriptionReceiptRequestedForOpenPaymentException() { + super(null, null); + } +} 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 9552a0061..e60581e94 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -21,15 +21,12 @@ import static org.whispersystems.textsecuregcm.util.AttributeValues.n; import static org.whispersystems.textsecuregcm.util.AttributeValues.s; import com.fasterxml.jackson.databind.ObjectMapper; -import com.stripe.exception.ApiException; import com.stripe.model.PaymentIntent; import io.dropwizard.auth.AuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Response; -import java.io.IOException; -import java.io.UncheckedIOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; @@ -79,7 +76,8 @@ import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper; 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.subscriptions.SubscriptionChargeFailurePaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionException; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.Subscriptions; import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; @@ -95,6 +93,13 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.SystemMapper; @@ -338,7 +343,7 @@ class SubscriptionControllerTest { when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong(), any())) - .thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ProcessorException(PaymentProvider.BRAINTREE, + .thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.BRAINTREE, new ChargeFailure("2046", "Declined", null, null, null)))); final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/paypal/confirm") @@ -417,7 +422,7 @@ class SubscriptionControllerTest { @Test void createSubscriptionProcessorDeclined() throws SubscriptionException { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenThrow(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE, + .thenThrow(new SubscriptionProcessorException(PaymentProvider.STRIPE, new ChargeFailure("card_declined", "Insufficient funds", null, null, null))); final String level = String.valueOf(levelId); @@ -491,9 +496,9 @@ class SubscriptionControllerTest { @Test void stripePaymentIntentRequiresAction() - throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException { + throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenThrow(new SubscriptionException.PaymentRequiresAction()); + .thenThrow(new SubscriptionPaymentRequiresActionException()); final String level = String.valueOf(levelId); final String idempotencyKey = UUID.randomUUID().toString(); @@ -688,7 +693,7 @@ class SubscriptionControllerTest { "201, M4", }) void setSubscriptionLevel(long levelId, String expectedProcessorId) - throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { + throws SubscriptionProcessorConflictException, SubscriptionProcessorException { // set up record final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -735,7 +740,7 @@ class SubscriptionControllerTest { @MethodSource void setSubscriptionLevelExistingSubscription(final String existingCurrency, final long existingLevel, final String requestCurrency, final long requestLevel, final boolean expectUpdate) - throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { + throws SubscriptionProcessorConflictException, SubscriptionProcessorException { // set up record final byte[] subscriberUserAndKey = new byte[32]; @@ -854,7 +859,7 @@ class SubscriptionControllerTest { @Test public void setAppStoreTransactionId() - throws SubscriptionException.InvalidArguments, SubscriptionException.PaymentRequired, RateLimitExceededException, SubscriptionException.NotFound { + throws SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException, SubscriptionNotFoundException { final String originalTxId = "aTxId"; final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -1003,7 +1008,7 @@ class SubscriptionControllerTest { b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()), Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId")))))); when(STRIPE_MANAGER.getReceiptItem(any())) - .thenThrow(new SubscriptionException.ChargeFailurePaymentRequired( + .thenThrow(new SubscriptionChargeFailurePaymentRequiredException( PaymentProvider.STRIPE, new ChargeFailure("card_declined", "Insufficient funds", null, null, null))); @@ -1027,7 +1032,7 @@ class SubscriptionControllerTest { @ParameterizedTest @CsvSource({"5, P45D", "201, P13D"}) public void createReceiptCredential(long level, Duration expectedExpirationWindow) - throws InvalidInputException, VerificationFailedException, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment { + throws InvalidInputException, VerificationFailedException, SubscriptionChargeFailurePaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException { final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java index a78dd3e71..165985d71 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java @@ -34,16 +34,11 @@ import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; class AppleAppStoreManagerTest { @@ -101,7 +96,7 @@ class AppleAppStoreManagerTest { public void generateReceiptExpired() throws VerificationException, APIException, IOException { mockSubscription(Status.EXPIRED, AutoRenewStatus.ON); - assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + assertThatExceptionOfType(SubscriptionPaymentRequiredException.class) .isThrownBy(() -> appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID)); } @@ -196,7 +191,7 @@ class AppleAppStoreManagerTest { @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"EXPIRED", "REVOKED"}) public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException { mockSubscription(status, AutoRenewStatus.ON); - assertThatExceptionOfType(SubscriptionException.InvalidArguments.class) + assertThatExceptionOfType(SubscriptionInvalidArgumentsException.class) .isThrownBy(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java index 3f065121f..60a71c55b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java @@ -41,7 +41,6 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MutableClock; @@ -133,7 +132,7 @@ class GooglePlayBillingManagerTest { switch (subscriptionState) { case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException() .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN)); - default -> assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + default -> assertThatExceptionOfType(SubscriptionPaymentRequiredException.class) .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN)); } } @@ -234,7 +233,7 @@ class GooglePlayBillingManagerTest { // next second should be expired clock.setTimeInstant(day10.plus(Duration.ofSeconds(1))); - assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + assertThatExceptionOfType(SubscriptionPaymentRequiredException.class) .isThrownBy(() -> googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN)); } @@ -272,8 +271,8 @@ class GooglePlayBillingManagerTest { public static Stream tokenErrors() { return Stream.of( - Arguments.of(404, SubscriptionException.NotFound.class), - Arguments.of(410, SubscriptionException.NotFound.class), + Arguments.of(404, SubscriptionNotFoundException.class), + Arguments.of(410, SubscriptionNotFoundException.class), Arguments.of(400, HttpResponseException.class) ); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java index fd465e7c4..7599573e5 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java @@ -8,18 +8,11 @@ package org.whispersystems.textsecuregcm.subscriptions; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.braintreegateway.BraintreeGateway; -import com.braintreegateway.Customer; -import com.braintreegateway.CustomerGateway; -import com.google.cloud.pubsub.v1.Publisher; import com.stripe.StripeClient; import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Collections; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -32,8 +25,6 @@ import com.stripe.service.SubscriptionService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; -import org.whispersystems.textsecuregcm.storage.SubscriptionException; class StripeManagerTest { @@ -67,7 +58,7 @@ class StripeManagerTest { when(subscriptionService.create(any(), any())).thenThrow(stripeException); when(stripeClient.subscriptions()).thenReturn(subscriptionService); - assertThatExceptionOfType(SubscriptionException.PaymentRequiresAction.class).isThrownBy(() -> + assertThatExceptionOfType(SubscriptionPaymentRequiresActionException.class).isThrownBy(() -> stripeManager.createSubscription("customerId", "priceId", 1, 0)); } }