Simplify SubscriptionExceptions

This commit is contained in:
ravi-signal
2025-09-04 13:50:51 -05:00
committed by GitHub
parent 89b37015c6
commit a5423b6e21
27 changed files with 462 additions and 404 deletions

View File

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

View File

@@ -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<SubscriptionException> {
@VisibleForTesting
@@ -25,20 +33,20 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
public Response toResponse(final SubscriptionException exception) {
// Some exceptions have specific error body formats
if (exception instanceof SubscriptionException.InvalidAmount e) {
if (exception instanceof SubscriptionInvalidAmountException e) {
return Response
.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", e.getErrorCode()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ProcessorException e) {
if (exception instanceof SubscriptionProcessorException e) {
return Response.status(PROCESSOR_ERROR_STATUS_CODE)
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ChargeFailurePaymentRequired e) {
if (exception instanceof SubscriptionChargeFailurePaymentRequiredException e) {
return Response
.status(Response.Status.PAYMENT_REQUIRED)
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
@@ -48,11 +56,11 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
// Otherwise, we'll return a generic error message WebApplicationException, with a detailed error if one is provided
final Response.Status status = (switch (exception) {
case SubscriptionException.NotFound e -> 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;
});

View File

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

View File

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

View File

@@ -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<SubscriptionInformation> 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<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> 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 <R> 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 <T extends CustomerAwareSubscriptionPaymentProcessor, R> R addPaymentMethodToCustomer(
final SubscriberCredentials subscriberCredentials,
final T subscriptionPaymentProcessor,
final ClientPlatform clientPlatform,
final BiFunction<T, String, R> 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

View File

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

View File

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

View File

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

View File

@@ -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> R executeTokenOperation(final ApiCall<R> 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));
}

View File

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

View File

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

View File

@@ -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<String> errorDetail() {
return Optional.ofNullable(errorDetail);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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