Make SubscriptionController synchronous

This commit is contained in:
ravi-signal
2025-09-02 15:11:05 -05:00
committed by GitHub
parent f52a262741
commit 774cc52b61
18 changed files with 1530 additions and 1381 deletions

View File

@@ -542,11 +542,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
// TODO: generally speaking this is a DynamoDB I/O executor for the accounts table; we should eventually have a general executor for speaking to the accounts table, but most of the server is still synchronous so this isn't widely useful yet
ExecutorService batchIdentityCheckExecutor = ExecutorServiceBuilder.of(environment, "batchIdentityCheck").minThreads(32).maxThreads(32).build();
ExecutorService subscriptionProcessorExecutor = ExecutorServiceBuilder.of(environment, "subscriptionProcessor")
.maxThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
.minThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
.allowCoreThreadTimeOut(true).
build();
ExecutorService receiptSenderExecutor = ExecutorServiceBuilder.of(environment, "receiptSender")
.maxThreads(2)
.minThreads(2)
@@ -573,12 +569,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.minThreads(2)
.build();
ExecutorService googlePlayBillingExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
"googlePlayBilling",
config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),
environment);
ExecutorService appleAppStoreExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
"appleAppStore",
ExecutorService subscriptionProcessorExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
"subscriptionProcessor",
config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),
environment);
ExecutorService clientEventExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(
@@ -590,8 +582,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),
environment);
ScheduledExecutorService appleAppStoreRetryExecutor = ScheduledExecutorServiceBuilder.of(environment, "appleAppStoreRetry").threads(1).build();
ScheduledExecutorService subscriptionProcessorRetryExecutor = ScheduledExecutorServiceBuilder.of(environment, "subscriptionProcessorRetry").threads(1).build();
ScheduledExecutorService cloudflareTurnRetryExecutor = ScheduledExecutorServiceBuilder.of(environment, "cloudflareTurnRetry").threads(1).build();
ScheduledExecutorService messagePollExecutor = ScheduledExecutorServiceBuilder.of(environment, "messagePollExecutor").threads(1).build();
ScheduledExecutorService provisioningWebsocketTimeoutExecutor = ScheduledExecutorServiceBuilder.of(environment, "provisioningWebsocketTimeout").threads(1).build();
@@ -749,21 +739,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getBraintree().environment(),
config.getBraintree().supportedCurrenciesByPaymentMethod(), config.getBraintree().merchantAccounts(),
config.getBraintree().graphqlUrl(), currencyManager, config.getBraintree().pubSubPublisher().build(),
config.getBraintree().circuitBreakerConfigurationName(), subscriptionProcessorExecutor,
subscriptionProcessorRetryExecutor);
config.getBraintree().circuitBreakerConfigurationName(), subscriptionProcessorExecutor);
GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager(
new ByteArrayInputStream(config.getGooglePlayBilling().credentialsJson().value().getBytes(StandardCharsets.UTF_8)),
config.getGooglePlayBilling().packageName(),
config.getGooglePlayBilling().applicationName(),
config.getGooglePlayBilling().productIdToLevel(),
googlePlayBillingExecutor);
config.getGooglePlayBilling().productIdToLevel());
AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager(
config.getAppleAppStore().env(), config.getAppleAppStore().bundleId(), config.getAppleAppStore().appAppleId(),
config.getAppleAppStore().issuerId(), config.getAppleAppStore().keyId(),
config.getAppleAppStore().encodedKey().value(), config.getAppleAppStore().subscriptionGroupId(),
config.getAppleAppStore().productIdToLevel(),
config.getAppleAppStore().appleRootCerts(),
config.getAppleAppStore().retryConfigurationName(), appleAppStoreExecutor, appleAppStoreRetryExecutor);
config.getAppleAppStore().retryConfigurationName());
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(pushNotificationScheduler);

View File

@@ -26,6 +26,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import javax.annotation.Nonnull;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
@@ -86,64 +87,59 @@ public class DonationController {
specific error message suitable for logging will be included as text/plain body
""")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> redeemReceipt(
@ManagedAsync
public Response redeemReceipt(
@Auth final AuthenticatedDevice auth,
@NotNull @Valid final RedeemReceiptRequest request) {
return CompletableFuture.supplyAsync(() -> {
ReceiptCredentialPresentation receiptCredentialPresentation;
try {
receiptCredentialPresentation = receiptCredentialPresentationFactory
.build(request.getReceiptCredentialPresentation());
} catch (InvalidInputException e) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
.entity("invalid receipt credential presentation")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
ReceiptCredentialPresentation receiptCredentialPresentation;
try {
receiptCredentialPresentation = receiptCredentialPresentationFactory
.build(request.getReceiptCredentialPresentation());
} catch (InvalidInputException e) {
return Response.status(Status.BAD_REQUEST)
.entity("invalid receipt credential presentation")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}
try {
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
} catch (VerificationFailedException e) {
return Response.status(Status.BAD_REQUEST)
.entity("receipt credential presentation verification failed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
if (badgeId == null) {
return Response.serverError()
.entity("server does not recognize the requested receipt level")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier()).join();
if (!receiptMatched) {
return Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}
accountsManager.update(account, a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
if (request.isPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
try {
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
} catch (VerificationFailedException e) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
.entity("receipt credential presentation verification failed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
if (badgeId == null) {
return CompletableFuture.completedFuture(Response.serverError()
.entity("server does not recognize the requested receipt level")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
return accountsManager.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier())
.thenCompose(receiptMatched -> {
if (!receiptMatched) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
return accountsManager.updateAsync(account, a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
if (request.isPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
})
.thenApply(ignored -> Response.ok().build());
});
});
}).thenCompose(Function.identity());
});
return Response.ok().build();
}
}

View File

@@ -52,11 +52,11 @@ import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.backup.BackupManager;
@@ -74,6 +74,7 @@ import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
@@ -85,7 +86,6 @@ 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.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@@ -233,12 +233,14 @@ public class SubscriptionController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> deleteSubscriber(
@ManagedAsync
public Response deleteSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@PathParam("subscriberId") String subscriberId) throws SubscriptionException, RateLimitExceededException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.deleteSubscriber(subscriberCredentials).thenApply(unused -> Response.ok().build());
subscriptionManager.deleteSubscriber(subscriberCredentials);
return Response.ok().build();
}
@PUT
@@ -255,15 +257,17 @@ public class SubscriptionController {
@ApiResponse(responseCode = "200", description = "The subscriber was successfully created or refreshed")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is malformed")
public CompletableFuture<Response> updateSubscriber(
@ManagedAsync
public Response updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.updateSubscriber(subscriberCredentials).thenApply(record -> Response.ok().build());
subscriptionManager.updateSubscriber(subscriberCredentials);
return Response.ok().build();
}
record CreatePaymentMethodResponse(String clientSecret, PaymentProvider processor) {
public record CreatePaymentMethodResponse(String clientSecret, PaymentProvider processor) {
}
@@ -271,7 +275,8 @@ public class SubscriptionController {
@Path("/{subscriberId}/create_payment_method")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPaymentMethod(
@ManagedAsync
public CreatePaymentMethodResponse createPaymentMethod(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType,
@@ -290,13 +295,13 @@ public class SubscriptionController {
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
};
return subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
customerAwareSubscriptionPaymentProcessor,
getClientPlatform(userAgentString),
CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken)
.thenApply(token ->
Response.ok(new CreatePaymentMethodResponse(token, customerAwareSubscriptionPaymentProcessor.getProvider())).build());
final String token = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
customerAwareSubscriptionPaymentProcessor,
getClientPlatform(userAgentString),
CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken);
return new CreatePaymentMethodResponse(token, customerAwareSubscriptionPaymentProcessor.getProvider());
}
public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {}
@@ -307,7 +312,8 @@ public class SubscriptionController {
@Path("/{subscriberId}/create_payment_method/paypal")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalPaymentMethod(
@ManagedAsync
public CreatePayPalBillingAgreementResponse createPayPalPaymentMethod(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@NotNull @Valid CreatePayPalBillingAgreementRequest request,
@@ -321,17 +327,16 @@ public class SubscriptionController {
.findFirst()
.orElse(Locale.US);
return subscriptionManager.addPaymentMethodToCustomer(
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
braintreeManager,
getClientPlatform(userAgentString),
(mgr, customerId) ->
mgr.createPayPalBillingAgreement(request.returnUrl, request.cancelUrl, locale.toLanguageTag()))
.thenApply(billingAgreementApprovalDetails -> Response.ok(
new CreatePayPalBillingAgreementResponse(
billingAgreementApprovalDetails.approvalUrl(),
billingAgreementApprovalDetails.billingAgreementToken()))
.build());
.join();
return new CreatePayPalBillingAgreementResponse(
billingAgreementApprovalDetails.approvalUrl(),
billingAgreementApprovalDetails.billingAgreementToken());
}
private CustomerAwareSubscriptionPaymentProcessor getCustomerAwareProcessor(PaymentProvider processor) {
@@ -346,7 +351,8 @@ public class SubscriptionController {
@Path("/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> setDefaultPaymentMethodWithProcessor(
@ManagedAsync
public Response setDefaultPaymentMethodWithProcessor(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("processor") PaymentProvider processor,
@@ -356,7 +362,8 @@ public class SubscriptionController {
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processor);
return setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);
setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);
return Response.ok().build();
}
public record SetSubscriptionLevelSuccessResponse(long level) {
@@ -383,7 +390,8 @@ public class SubscriptionController {
@Path("/{subscriberId}/level/{level}/{currency}/{idempotencyKey}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> setSubscriptionLevel(
@ManagedAsync
public SetSubscriptionLevelSuccessResponse setSubscriptionLevel(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("level") long level,
@@ -391,42 +399,40 @@ public class SubscriptionController {
@PathParam("idempotencyKey") String idempotencyKey) throws SubscriptionException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.getSubscriber(subscriberCredentials)
.thenCompose(record -> {
final ProcessorCustomer processorCustomer = record.getProcessorCustomer()
.orElseThrow(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
new ClientErrorException(Status.CONFLICT));
try {
final Subscriptions.Record record = subscriptionManager.getSubscriber(subscriberCredentials);
final ProcessorCustomer processorCustomer = record.getProcessorCustomer()
.orElseThrow(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
new ClientErrorException(Status.CONFLICT));
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,
processorCustomer.processor());
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,
processorCustomer.processor());
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processorCustomer.processor());
return subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,
currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);
})
.exceptionally(ExceptionUtils.exceptionallyHandler(SubscriptionException.InvalidLevel.class, 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());
}))
.exceptionally(ExceptionUtils.exceptionallyHandler(SubscriptionException.PaymentRequiresAction.class, 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());
}))
.exceptionally(ExceptionUtils.exceptionallyHandler(SubscriptionException.InvalidArguments.class, 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()))))
.build());
}))
.thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build());
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(
processorCustomer.processor());
subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,
currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);
return new SetSubscriptionLevelSuccessResponse(level);
} catch (SubscriptionException.InvalidLevel 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) {
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) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(new SetSubscriptionLevelErrorResponse(List.of(new SetSubscriptionLevelErrorResponse.Error(
SetSubscriptionLevelErrorResponse.Error.Type.INVALID_ARGUMENTS, e.getMessage()))))
.build());
}
}
public boolean subscriptionsAreSameType(long level1, long level2) {
@@ -449,7 +455,7 @@ public class SubscriptionController {
4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the
entitlement
""")
@ApiResponse(responseCode = "200", description = "The originalTransactionId was successfully validated")
@ApiResponse(responseCode = "200", description = "The originalTransactionId was successfully validated", useReturnTypeSchema = true)
@ApiResponse(responseCode = "402", description = "The subscription transaction is incomplete or invalid")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the specified transaction does not exist")
@@ -457,16 +463,16 @@ public class SubscriptionController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
@ManagedAsync
public SetSubscriptionLevelSuccessResponse setAppStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("originalTransactionId") String originalTransactionId) throws SubscriptionException {
@PathParam("originalTransactionId") String originalTransactionId) throws SubscriptionException, RateLimitExceededException {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager
.updateAppStoreTransactionId(subscriberCredentials, appleAppStoreManager, originalTransactionId)
.thenApply(SetSubscriptionLevelSuccessResponse::new);
return new SetSubscriptionLevelSuccessResponse(subscriptionManager
.updateAppStoreTransactionId(subscriberCredentials, appleAppStoreManager, originalTransactionId));
}
@@ -493,7 +499,7 @@ public class SubscriptionController {
method. A different playbilling purchaseToken can be posted to the same subscriberId, in this case the subscription
associated with the old purchaseToken will be cancelled.
""")
@ApiResponse(responseCode = "200", description = "The purchaseToken was validated and acknowledged")
@ApiResponse(responseCode = "200", description = "The purchaseToken was validated and acknowledged", useReturnTypeSchema = true)
@ApiResponse(responseCode = "402", description = "The purchaseToken payment is incomplete or invalid")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
@@ -501,16 +507,16 @@ public class SubscriptionController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
@ManagedAsync
public SetSubscriptionLevelSuccessResponse setPlayStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException {
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException, RateLimitExceededException {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager
.updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager, purchaseToken)
.thenApply(SetSubscriptionLevelSuccessResponse::new);
return new SetSubscriptionLevelSuccessResponse(subscriptionManager
.updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager, purchaseToken));
}
@Schema(description = """
@@ -572,24 +578,21 @@ public class SubscriptionController {
description = """
Returns all configuration for badges, donation subscriptions, backup subscriptions, and one-time donation (
"boost" and "gift") minimum and suggested amounts.""")
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = GetSubscriptionConfigurationResponse.class)))
public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build();
});
@ApiResponse(responseCode = "200", useReturnTypeSchema = true)
@ManagedAsync
public GetSubscriptionConfigurationResponse getConfiguration(@Context ContainerRequestContext containerRequestContext) {
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
return buildGetSubscriptionConfigurationResponse(acceptableLanguages);
}
@GET
@Path("/bank_mandate/{bankTransferType}")
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> getBankMandate(final @Context ContainerRequestContext containerRequestContext,
@ManagedAsync
public GetBankMandateResponse getBankMandate(final @Context ContainerRequestContext containerRequestContext,
final @PathParam("bankTransferType") BankTransferType bankTransferType) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
return Response.ok(new GetBankMandateResponse(
bankMandateTranslator.translate(acceptableLanguages, bankTransferType))).build();
});
List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
return new GetBankMandateResponse(bankMandateTranslator.translate(acceptableLanguages, bankTransferType));
}
public record GetBankMandateResponse(String mandate) {}
@@ -652,19 +655,20 @@ public class SubscriptionController {
to Stripe's. Since we dont support trials or unpaid subscriptions, the associated statuses will never be returned
by the API.
""")
@ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class)))
@ApiResponse(responseCode = "200", description = "The subscriberId exists", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> getSubscriptionInformation(
@ManagedAsync
public GetSubscriptionInformationResponse getSubscriptionInformation(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@PathParam("subscriberId") String subscriberId) throws SubscriptionException, RateLimitExceededException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.getSubscriptionInformation(subscriberCredentials).thenApply(maybeInfo -> maybeInfo
.map(subscriptionInformation -> Response.ok(
return subscriptionManager.getSubscriptionInformation( subscriberCredentials)
.map(subscriptionInformation ->
new GetSubscriptionInformationResponse(
new GetSubscriptionInformationResponse.Subscription(
subscriptionInformation.level(),
@@ -679,8 +683,8 @@ public class SubscriptionController {
subscriptionInformation.paymentMethod(),
subscriptionInformation.paymentProcessing()),
subscriptionInformation.chargeFailure()
)).build())
.orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
))
.orElseGet(() -> new GetSubscriptionInformationResponse(null, null));
}
public record GetReceiptCredentialsRequest(
@@ -717,7 +721,7 @@ public class SubscriptionController {
Clients MUST validate that the generated receipt credential's level and expiration matches their expectations.
""")
@ApiResponse(responseCode = "200", description = "Successfully created receipt", content = @Content(schema = @Schema(implementation = GetReceiptCredentialsResponse.class)))
@ApiResponse(responseCode = "200", description = "Successfully created receipt", useReturnTypeSchema = true)
@ApiResponse(responseCode = "204", description = "No invoice has been issued for this subscription OR invoice is in 'open' state")
@ApiResponse(responseCode = "400", description = "Bad ReceiptCredentialRequest")
@ApiResponse(responseCode = "402", description = "Invoice is in any state other than 'open' or 'paid'. May include chargeFailure details in body.",
@@ -741,63 +745,68 @@ public class SubscriptionController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> createSubscriptionReceiptCredentials(
@ManagedAsync
public Response createSubscriptionReceiptCredentials(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@PathParam("subscriberId") String subscriberId,
@NotNull @Valid GetReceiptCredentialsRequest request) throws SubscriptionException {
@NotNull @Valid GetReceiptCredentialsRequest request) throws SubscriptionException, RateLimitExceededException {
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.createReceiptCredentials(subscriberCredentials, request, this::receiptExpirationWithGracePeriod)
.thenApply(receiptCredential -> {
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),
Tag.of(TYPE_TAG_NAME, "subscription"),
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
subscriptionConfiguration.getSubscriptionLevel(receipt.level()).type().name()
.toLowerCase(Locale.ROOT)),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build();
})
.exceptionally(ExceptionUtils.exceptionallyHandler(
SubscriptionException.ReceiptRequestedForOpenPayment.class,
e -> Response.noContent().build()));
try {
final SubscriptionManager.ReceiptResult receiptCredential = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request, this::receiptExpirationWithGracePeriod);
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),
Tag.of(TYPE_TAG_NAME, "subscription"),
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
subscriptionConfiguration.getSubscriptionLevel(receipt.level()).type().name()
.toLowerCase(Locale.ROOT)),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build();
} catch (SubscriptionException.ReceiptRequestedForOpenPayment e) {
return Response.noContent().build();
}
}
@POST
@Path("/{subscriberId}/default_payment_method_for_ideal/{setupIntentId}")
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> setDefaultPaymentMethodForIdeal(
@ManagedAsync
public Response setDefaultPaymentMethodForIdeal(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@PathParam("setupIntentId") @NotEmpty String setupIntentId) throws SubscriptionException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
return stripeManager.getGeneratedSepaIdFromSetupIntent(setupIntentId)
.thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials));
final String generatedSepaId = stripeManager.getGeneratedSepaIdFromSetupIntent(setupIntentId).join();
setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials);
return Response.ok().build();
}
private CompletableFuture<Response> setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,
private void setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,
final String paymentMethodId,
final SubscriberCredentials requestData) {
return subscriptionManager.getSubscriber(requestData)
.thenCompose(record -> record.getProcessorCustomer()
.map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(),
paymentMethodId, record.subscriptionId))
.orElseThrow(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
new ClientErrorException(Status.CONFLICT)))
.exceptionally(ExceptionUtils.exceptionallyHandler(SubscriptionException.InvalidArguments.class, 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);
}))
.thenApply(customer -> Response.ok().build());
final SubscriberCredentials requestData) throws SubscriptionException {
try {
final Subscriptions.Record record = subscriptionManager.getSubscriber(requestData);
final ProcessorCustomer processorCustomer = record.getProcessorCustomer()
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
.orElseThrow(() ->new ClientErrorException(Status.CONFLICT));
manager
.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), paymentMethodId, record.subscriptionId);
} catch (SubscriptionException.InvalidArguments 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);
}
}
private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {

View File

@@ -5,13 +5,14 @@
package org.whispersystems.textsecuregcm.storage;
import com.stripe.exception.StripeException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -21,6 +22,7 @@ import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
@@ -30,7 +32,6 @@ 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.util.Util;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
/**
@@ -64,24 +65,26 @@ 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
* @return A stage that completes when the subscription has been cancelled with the upstream payment provider and the
* subscription has been removed from the table.
* @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
*/
public CompletableFuture<Void> deleteSubscriber(final SubscriberCredentials subscriberCredentials) {
return subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac())
.thenCompose(getResult -> {
if (getResult == Subscriptions.GetResult.NOT_STORED
|| getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {
return CompletableFuture.failedFuture(new SubscriptionException.NotFound());
}
return getResult.record.getProcessorCustomer()
.map(processorCustomer -> getProcessor(processorCustomer.processor())
.cancelAllActiveSubscriptions(processorCustomer.customerId()))
// a missing customer ID is OK; it means the subscriber never started to add a payment method
.orElseGet(() -> CompletableFuture.completedFuture(null));
})
.thenCompose(unused ->
subscriptions.setCanceledAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()));
public void deleteSubscriber(final SubscriberCredentials subscriberCredentials)
throws SubscriptionException.NotFound, SubscriptionException.InvalidArguments, 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();
}
// a missing customer ID is OK; it means the subscriber never started to add a payment method, so we can skip cancelling
if (getResult.record.getProcessorCustomer().isPresent()) {
final ProcessorCustomer processorCustomer = getResult.record.getProcessorCustomer().get();
getProcessor(processorCustomer.processor()).cancelAllActiveSubscriptions(processorCustomer.customerId());
}
subscriptions.setCanceledAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()).join();
}
/**
@@ -91,60 +94,58 @@ public class SubscriptionManager {
* already exists, its last access time will be updated.
*
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
* @return A stage that completes when the subscriber has been updated.
* @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect
*/
public CompletableFuture<Void> updateSubscriber(final SubscriberCredentials subscriberCredentials) {
return subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac())
.thenCompose(getResult -> {
if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {
return CompletableFuture.failedFuture(new SubscriptionException.Forbidden("subscriberId mismatch"));
} else if (getResult == Subscriptions.GetResult.NOT_STORED) {
// create a customer and write it to ddb
return subscriptions.create(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac(),
subscriberCredentials.now())
.thenApply(updatedRecord -> {
if (updatedRecord == null) {
throw ExceptionUtils.wrap(new SubscriptionException.Forbidden("subscriberId mismatch"));
}
return updatedRecord;
});
} else {
// already exists so just touch access time and return
return subscriptions.accessedAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now())
.thenApply(unused -> getResult.record);
}
})
.thenRun(Util.NOOP);
public void updateSubscriber(final SubscriberCredentials subscriberCredentials)
throws SubscriptionException.Forbidden {
final Subscriptions.GetResult getResult =
subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join();
if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {
throw new SubscriptionException.Forbidden("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");
}
} else {
// already exists so just touch access time and return
subscriptions.accessedAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()).join();
}
}
public CompletableFuture<Optional<SubscriptionInformation>> getSubscriptionInformation(
final SubscriberCredentials subscriberCredentials) {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.completedFuture(Optional.empty());
}
final SubscriptionPaymentProcessor manager = getProcessor(record.processorCustomer.processor());
return manager.getSubscriptionInformation(record.subscriptionId).thenApply(Optional::of);
});
public Optional<SubscriptionInformation> getSubscriptionInformation(
final SubscriberCredentials subscriberCredentials)
throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.InvalidArguments, RateLimitExceededException {
final Subscriptions.Record record = getSubscriber(subscriberCredentials);
if (record.subscriptionId == null) {
return Optional.empty();
}
final SubscriptionPaymentProcessor manager = getProcessor(record.processorCustomer.processor());
return Optional.of(manager.getSubscriptionInformation(record.subscriptionId));
}
/**
* Get the subscriber record
*
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
* @return A stage that completes with the requested subscriber if it exists and the credentials are correct.
* @throws SubscriptionException.Forbidden if the subscriber credentials were incorrect
* @throws SubscriptionException.NotFound if the subscriber did not exist
*/
public CompletableFuture<Subscriptions.Record> getSubscriber(final SubscriberCredentials subscriberCredentials) {
return subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac())
.thenApply(getResult -> {
if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {
throw ExceptionUtils.wrap(new SubscriptionException.Forbidden("subscriberId mismatch"));
} else if (getResult == Subscriptions.GetResult.NOT_STORED) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
} else {
return getResult.record;
}
});
public Subscriptions.Record getSubscriber(final SubscriberCredentials subscriberCredentials)
throws SubscriptionException.Forbidden, SubscriptionException.NotFound {
final Subscriptions.GetResult getResult =
subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join();
if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {
throw new SubscriptionException.Forbidden("subscriberId mismatch");
} else if (getResult == Subscriptions.GetResult.NOT_STORED) {
throw new SubscriptionException.NotFound();
} else {
return getResult.record;
}
}
public record ReceiptResult(
@@ -159,46 +160,53 @@ public class SubscriptionManager {
* @param request The ZK Receipt credential request
* @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem}
* and returns the expiration time of the receipt
* @return If the subscription had a valid payment, the requested ZK receipt credential
* @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
*/
public CompletableFuture<ReceiptResult> createReceiptCredentials(
public ReceiptResult createReceiptCredentials(
final SubscriberCredentials subscriberCredentials,
final SubscriptionController.GetReceiptCredentialsRequest request,
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.subscriptionId == null) {
return CompletableFuture.failedFuture(new SubscriptionException.NotFound());
}
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration)
throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.InvalidArguments, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, RateLimitExceededException, SubscriptionException.ReceiptRequestedForOpenPayment {
final Subscriptions.Record record = getSubscriber(subscriberCredentials);
if (record.subscriptionId == null) {
throw new SubscriptionException.NotFound();
}
ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest());
} catch (InvalidInputException e) {
return CompletableFuture.failedFuture(
new SubscriptionException.InvalidArguments("invalid receipt credential request", e));
}
ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest());
} catch (InvalidInputException e) {
throw new SubscriptionException.InvalidArguments("invalid receipt credential request", e);
}
final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();
final SubscriptionPaymentProcessor manager = getProcessor(processor);
return manager.getReceiptItem(record.subscriptionId)
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
receipt.itemId(), manager.getProvider(), receiptCredentialRequest,
subscriberCredentials.now())
.thenApply(unused -> receipt))
.thenApply(receipt -> {
ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest,
expiration.apply(receipt).getEpochSecond(),
receipt.level());
} catch (VerificationFailedException e) {
throw ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments("receipt credential request failed verification", e));
}
return new ReceiptResult(receiptCredentialResponse, receipt, processor);
});
});
final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();
final SubscriptionPaymentProcessor manager = getProcessor(processor);
final SubscriptionPaymentProcessor.ReceiptItem receipt = manager.getReceiptItem(record.subscriptionId);
issuedReceiptsManager
.recordIssuance(receipt.itemId(), manager.getProvider(), receiptCredentialRequest, subscriberCredentials.now())
.join();
ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest,
expiration.apply(receipt).getEpochSecond(),
receipt.level());
} catch (VerificationFailedException e) {
throw new SubscriptionException.InvalidArguments("receipt credential request failed verification", e);
}
return new ReceiptResult(receiptCredentialResponse, receipt, processor);
}
/**
@@ -219,37 +227,35 @@ public class SubscriptionManager {
* @param <T> A payment processor that has a notion of server-managed customers
* @param <R> The return type of the paymentSetupFunction, which should be used by a client
* to configure the newly created payment method
* @return A stage that completes when the payment method has been created in the payment processor and the table has
* been updated
* @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
*/
public <T extends CustomerAwareSubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
public <T extends CustomerAwareSubscriptionPaymentProcessor, R> R addPaymentMethodToCustomer(
final SubscriberCredentials subscriberCredentials,
final T subscriptionPaymentProcessor,
final ClientPlatform clientPlatform,
final BiFunction<T, String, CompletableFuture<R>> paymentSetupFunction) {
return this.getSubscriber(subscriberCredentials).thenCompose(record -> record.getProcessorCustomer()
.map(ProcessorCustomer::processor)
.map(processor -> {
if (processor != subscriptionPaymentProcessor.getProvider()) {
return CompletableFuture.<Subscriptions.Record>failedFuture(
new SubscriptionException.ProcessorConflict("existing processor does not match"));
}
return CompletableFuture.completedFuture(record);
})
.orElseGet(() -> subscriptionPaymentProcessor
.createCustomer(subscriberCredentials.subscriberUser(), clientPlatform)
.thenApply(ProcessorCustomer::customerId)
.thenCompose(customerId -> subscriptions.setProcessorAndCustomerId(record,
new ProcessorCustomer(customerId, subscriptionPaymentProcessor.getProvider()),
Instant.now()))))
.thenCompose(updatedRecord -> {
final String customerId = updatedRecord.getProcessorCustomer()
.filter(pc -> pc.processor().equals(subscriptionPaymentProcessor.getProvider()))
.orElseThrow(() ->
ExceptionUtils.wrap(new SubscriptionException(null, "record should not be missing customer")))
.customerId();
return paymentSetupFunction.apply(subscriptionPaymentProcessor, customerId);
});
final BiFunction<T, String, R> paymentSetupFunction)
throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.ProcessorConflict {
Subscriptions.Record record = this.getSubscriber(subscriberCredentials);
if (record.getProcessorCustomer().isEmpty()) {
final ProcessorCustomer pc = subscriptionPaymentProcessor
.createCustomer(subscriberCredentials.subscriberUser(), clientPlatform);
record = subscriptions.setProcessorAndCustomerId(record,
new ProcessorCustomer(pc.customerId(), subscriptionPaymentProcessor.getProvider()),
Instant.now()).join();
}
final ProcessorCustomer processorCustomer = record.getProcessorCustomer()
.orElseThrow(() -> new UncheckedIOException(new IOException("processor must now exist")));
if (processorCustomer.processor() != subscriptionPaymentProcessor.getProvider()) {
throw new SubscriptionException.ProcessorConflict("existing processor does not match");
}
return paymentSetupFunction.apply(subscriptionPaymentProcessor, processorCustomer.customerId());
}
public interface LevelTransitionValidator {
@@ -282,9 +288,15 @@ 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
* @return A stage that completes when the level has been updated in the payment processor and the table
* @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
*/
public CompletableFuture<Void> updateSubscriptionLevelForCustomer(
public void updateSubscriptionLevelForCustomer(
final SubscriberCredentials subscriberCredentials,
final Subscriptions.Record record,
final CustomerAwareSubscriptionPaymentProcessor processor,
@@ -292,50 +304,45 @@ public class SubscriptionManager {
final String currency,
final String idempotencyKey,
final String subscriptionTemplateId,
final LevelTransitionValidator transitionValidator) {
final LevelTransitionValidator transitionValidator)
throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException {
return Optional.ofNullable(record.subscriptionId)
if (record.subscriptionId != null) {
// we already have a subscription in our records so let's check the level and currency,
// and only change it if needed
final Object subscription = processor.getSubscription(record.subscriptionId);
final CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency existingLevelAndCurrency =
processor.getLevelAndCurrencyForSubscription(subscription);
final CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency desiredLevelAndCurrency =
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level, currency.toLowerCase(Locale.ROOT));
if (existingLevelAndCurrency.equals(desiredLevelAndCurrency)) {
return;
}
if (!transitionValidator.isTransitionValid(existingLevelAndCurrency.level(), level)) {
throw new SubscriptionException.InvalidLevel();
}
final CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updatedSubscriptionId =
processor.updateSubscription(subscription, subscriptionTemplateId, level, idempotencyKey);
// we already have a subscription in our records so let's check the level and currency,
// and only change it if needed
.map(subId -> processor
.getSubscription(subId)
.thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription)
.thenCompose(existingLevelAndCurrency -> {
if (existingLevelAndCurrency.equals(
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level,
currency.toLowerCase(Locale.ROOT)))) {
return CompletableFuture.completedFuture(null);
}
if (!transitionValidator.isTransitionValid(existingLevelAndCurrency.level(), level)) {
return CompletableFuture.failedFuture(new SubscriptionException.InvalidLevel());
}
return processor.updateSubscription(subscription, subscriptionTemplateId, level, idempotencyKey)
.thenCompose(updatedSubscription ->
subscriptions.subscriptionLevelChanged(subscriberCredentials.subscriberUser(),
subscriberCredentials.now(),
level, updatedSubscription.id()));
})))
subscriptions.subscriptionLevelChanged(subscriberCredentials.subscriberUser(),
subscriberCredentials.now(),
level,
updatedSubscriptionId.id()).join();
} else {
// Otherwise, we don't have a subscription yet so create it and then record the subscription id
long lastSubscriptionCreatedAt = record.subscriptionCreatedAt != null
? record.subscriptionCreatedAt.getEpochSecond()
: 0;
// Otherwise, we don't have a subscription yet so create it and then record the subscription id
.orElseGet(() -> {
long lastSubscriptionCreatedAt = record.subscriptionCreatedAt != null
? record.subscriptionCreatedAt.getEpochSecond()
: 0;
final CustomerAwareSubscriptionPaymentProcessor.SubscriptionId subscription = processor.createSubscription(
record.processorCustomer.customerId(),
subscriptionTemplateId,
level,
lastSubscriptionCreatedAt);
subscriptions.subscriptionCreated(
subscriberCredentials.subscriberUser(), subscription.id(), subscriberCredentials.now(), level);
return processor.createSubscription(record.processorCustomer.customerId(),
subscriptionTemplateId,
level,
lastSubscriptionCreatedAt)
.exceptionally(ExceptionUtils.exceptionallyHandler(StripeException.class, stripeException -> {
if ("subscription_payment_intent_requires_action".equals(stripeException.getCode())) {
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequiresAction());
}
throw ExceptionUtils.wrap(stripeException);
}))
.thenCompose(subscription -> subscriptions.subscriptionCreated(
subscriberCredentials.subscriberUser(), subscription.id(), subscriberCredentials.now(), level));
});
}
}
/**
@@ -345,49 +352,50 @@ public class SubscriptionManager {
* @param googlePlayBillingManager Performs play billing API operations
* @param purchaseToken The client provided purchaseToken that represents a purchased subscription in the
* play store
* @return A stage that completes with the subscription level for the accepted subscription
* @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
*/
public CompletableFuture<Long> updatePlayBillingPurchaseToken(
public long updatePlayBillingPurchaseToken(
final SubscriberCredentials subscriberCredentials,
final GooglePlayBillingManager googlePlayBillingManager,
final String purchaseToken) {
final String purchaseToken)
throws SubscriptionException.ProcessorConflict, SubscriptionException.Forbidden, SubscriptionException.NotFound, RateLimitExceededException, SubscriptionException.PaymentRequired {
// For IAP providers, the subscriptionId and the customerId are both just the purchaseToken. Changes to the
// subscription always just result in a new purchaseToken
final ProcessorCustomer pc = new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING);
return getSubscriber(subscriberCredentials)
final Subscriptions.Record record = getSubscriber(subscriberCredentials);
// Check the record for an existing subscription
.thenCompose(record -> {
if (record.processorCustomer != null
&& record.processorCustomer.processor() != PaymentProvider.GOOGLE_PLAY_BILLING) {
return CompletableFuture.failedFuture(
new SubscriptionException.ProcessorConflict("existing processor does not match"));
}
// 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");
}
// If we're replacing an existing purchaseToken, cancel it first
return Optional.ofNullable(record.processorCustomer)
.map(ProcessorCustomer::customerId)
.filter(existingToken -> !purchaseToken.equals(existingToken))
.map(googlePlayBillingManager::cancelAllActiveSubscriptions)
.orElseGet(() -> CompletableFuture.completedFuture(null))
.thenApply(ignored -> record);
})
// If we're replacing an existing purchaseToken, cancel it first
if (record.processorCustomer != null && !purchaseToken.equals(record.processorCustomer.customerId())) {
googlePlayBillingManager.cancelAllActiveSubscriptions(record.processorCustomer.customerId());
}
// Validate and set the purchaseToken
.thenCompose(record -> googlePlayBillingManager
// Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager,
// but we don't want to acknowledge it until it's successfully persisted.
final GooglePlayBillingManager.ValidatedToken validatedToken = googlePlayBillingManager.validateToken(purchaseToken);
// Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager,
// but we don't want to acknowledge it until it's successfully persisted.
.validateToken(purchaseToken)
// Store the valid purchaseToken with the subscriber
subscriptions.setIapPurchase(record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now());
// Store the purchaseToken with the subscriber
.thenCompose(validatedToken -> subscriptions.setIapPurchase(
record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now())
// Now that the purchaseToken is durable, we can acknowledge it
.thenCompose(ignore -> validatedToken.acknowledgePurchase())
.thenApply(ignore -> validatedToken.getLevel())));
// Now that the purchaseToken is durable, we can acknowledge it
validatedToken.acknowledgePurchase();
return validatedToken.getLevel();
}
/**
@@ -397,32 +405,37 @@ public class SubscriptionManager {
* @param appleAppStoreManager Performs app store API operations
* @param originalTransactionId The client provided originalTransactionId that represents a purchased subscription in
* the app store
* @return A stage that completes with the subscription level for the accepted subscription
* @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
*/
public CompletableFuture<Long> updateAppStoreTransactionId(
public long updateAppStoreTransactionId(
final SubscriberCredentials subscriberCredentials,
final AppleAppStoreManager appleAppStoreManager,
final String originalTransactionId) {
final String originalTransactionId)
throws SubscriptionException.Forbidden, SubscriptionException.NotFound, SubscriptionException.ProcessorConflict, SubscriptionException.InvalidArguments, SubscriptionException.PaymentRequired, RateLimitExceededException {
return getSubscriber(subscriberCredentials).thenCompose(record -> {
if (record.processorCustomer != null
&& record.processorCustomer.processor() != PaymentProvider.APPLE_APP_STORE) {
return CompletableFuture.failedFuture(
new SubscriptionException.ProcessorConflict("existing processor does not match"));
}
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");
}
// For IAP providers, the subscriptionId and the customerId are both just the identifier for the subscription in
// the provider (in this case, the originalTransactionId). Changes to the subscription always just result in a new
// originalTransactionId
final ProcessorCustomer pc = new ProcessorCustomer(originalTransactionId, PaymentProvider.APPLE_APP_STORE);
return appleAppStoreManager
.validateTransaction(originalTransactionId)
.thenCompose(level -> subscriptions
.setIapPurchase(record, pc, originalTransactionId, level, subscriberCredentials.now())
.thenApply(ignore -> level));
});
// For IAP providers, the subscriptionId and the customerId are both just the identifier for the subscription in
// the provider (in this case, the originalTransactionId). Changes to the subscription always just result in a new
// originalTransactionId
final ProcessorCustomer pc = new ProcessorCustomer(originalTransactionId, PaymentProvider.APPLE_APP_STORE);
final Long level = appleAppStoreManager.validateTransaction(originalTransactionId);
subscriptions.setIapPurchase(record, pc, originalTransactionId, level, subscriberCredentials.now()).join();
return level;
}
private SubscriptionPaymentProcessor getProcessor(PaymentProvider provider) {

View File

@@ -24,6 +24,7 @@ import io.micrometer.core.instrument.Metrics;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.net.http.HttpResponse;
import java.time.Instant;
@@ -31,14 +32,8 @@ import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.slf4j.Logger;
@@ -48,7 +43,6 @@ import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.ResilienceUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
/**
* Manages subscriptions made with the Apple App Store
@@ -63,8 +57,6 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
private final AppStoreServerAPIClient apiClient;
private final SignedDataVerifier signedDataVerifier;
private final ExecutorService executor;
private final ScheduledExecutorService retryExecutor;
private final Map<String, Long> productIdToLevel;
private static final Status[] EMPTY_STATUSES = new Status[0];
@@ -86,12 +78,10 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
final String subscriptionGroupId,
final Map<String, Long> productIdToLevel,
final List<String> base64AppleRootCerts,
@Nullable final String retryConfigurationName,
final ExecutorService executor,
final ScheduledExecutorService retryExecutor) {
@Nullable final String retryConfigurationName) {
this(new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, env),
new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, env, true),
subscriptionGroupId, productIdToLevel, retryConfigurationName, executor, retryExecutor);
subscriptionGroupId, productIdToLevel, retryConfigurationName);
}
@VisibleForTesting
@@ -100,25 +90,16 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
final SignedDataVerifier signedDataVerifier,
final String subscriptionGroupId,
final Map<String, Long> productIdToLevel,
@Nullable final String retryConfigurationName,
final ExecutorService executor,
final ScheduledExecutorService retryExecutor) {
@Nullable final String retryConfigurationName) {
this.apiClient = apiClient;
this.signedDataVerifier = signedDataVerifier;
this.subscriptionGroupId = subscriptionGroupId;
this.productIdToLevel = productIdToLevel;
this.executor = Objects.requireNonNull(executor);
this.retryExecutor = Objects.requireNonNull(retryExecutor);
final RetryConfig.Builder<HttpResponse<?>> retryConfigBuilder =
RetryConfig.from(Optional.ofNullable(retryConfigurationName)
this.retry = ResilienceUtil.getRetryRegistry().retry("appstore-retry", RetryConfig
.<HttpResponse<?>>from(Optional.ofNullable(retryConfigurationName)
.flatMap(name -> ResilienceUtil.getRetryRegistry().getConfiguration(name))
.orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig()));
retryConfigBuilder.retryOnException(AppleAppStoreManager::shouldRetry);
this.retry = ResilienceUtil.getRetryRegistry()
.retry(ResilienceUtil.name(AppleAppStoreManager.class, "appstore-retry"), retryConfigBuilder.build());
.orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig()))
.retryOnException(AppleAppStoreManager::shouldRetry).build());
}
@Override
@@ -131,16 +112,20 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
* Check if the subscription with the provided originalTransactionId is valid.
*
* @param originalTransactionId The originalTransactionId associated with the subscription
* @return A stage that completes successfully when the transaction has been validated, or fails if the token does not
* represent an active 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
* grant the user an entitlement
* @throws SubscriptionException.InvalidArguments If the transaction is valid but does not contain a subscription
*/
public CompletableFuture<Long> validateTransaction(final String originalTransactionId) {
return lookup(originalTransactionId).thenApplyAsync(tx -> {
if (!isSubscriptionActive(tx)) {
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
}
return getLevel(tx);
}, executor);
public Long validateTransaction(final String originalTransactionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired {
final DecodedTransaction tx = lookup(originalTransactionId);
if (!isSubscriptionActive(tx)) {
throw new SubscriptionException.PaymentRequired();
}
return getLevel(tx);
}
@@ -152,125 +137,128 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
* this method.
*
* @param originalTransactionId The originalTransactionId associated with the subscription
* @return A stage that completes when the subscription has successfully been cancelled
* @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
* the transaction has not already been cancelled with storekit
*/
@Override
public CompletableFuture<Void> cancelAllActiveSubscriptions(String originalTransactionId) {
return lookup(originalTransactionId).thenApplyAsync(tx -> {
if (tx.signedTransaction.getStatus() != Status.EXPIRED &&
tx.signedTransaction.getStatus() != Status.REVOKED &&
tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) {
throw ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments("must cancel subscription with storekit before deleting"));
}
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");
}
// The subscription will not auto-renew, so we can stop tracking it
return null;
}, executor);
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String originalTransactionId) {
return lookup(originalTransactionId).thenApplyAsync(tx -> {
public SubscriptionInformation getSubscriptionInformation(final String originalTransactionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound {
final DecodedTransaction tx = lookup(originalTransactionId);
final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) {
case ACTIVE -> SubscriptionStatus.ACTIVE;
case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;
case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;
};
final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) {
case ACTIVE -> SubscriptionStatus.ACTIVE;
case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;
case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;
};
return new SubscriptionInformation(
getSubscriptionPrice(tx),
getLevel(tx),
Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()),
Instant.ofEpochMilli(tx.transaction.getExpiresDate()),
isSubscriptionActive(tx),
tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF,
status,
PaymentProvider.APPLE_APP_STORE,
PaymentMethod.APPLE_APP_STORE,
false,
null);
}, executor);
return new SubscriptionInformation(
getSubscriptionPrice(tx),
getLevel(tx),
Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()),
Instant.ofEpochMilli(tx.transaction.getExpiresDate()),
isSubscriptionActive(tx),
tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF,
status,
PaymentProvider.APPLE_APP_STORE,
PaymentMethod.APPLE_APP_STORE,
false,
null);
}
@Override
public CompletableFuture<ReceiptItem> getReceiptItem(String originalTransactionId) {
return lookup(originalTransactionId).thenApplyAsync(tx -> {
if (!isSubscriptionActive(tx)) {
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
}
public ReceiptItem getReceiptItem(String originalTransactionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired {
final DecodedTransaction tx = lookup(originalTransactionId);
if (!isSubscriptionActive(tx)) {
throw new SubscriptionException.PaymentRequired();
}
// A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is
// guaranteed not to change for a specific renewal purchase.
// See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid
final String itemId = tx.transaction.getWebOrderLineItemId();
final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate()));
// A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is
// guaranteed not to change for a specific renewal purchase.
// See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid
final String itemId = tx.transaction.getWebOrderLineItemId();
final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate()));
return new ReceiptItem(itemId, paymentTime, getLevel(tx));
return new ReceiptItem(itemId, paymentTime, getLevel(tx));
}, executor);
}
private CompletableFuture<DecodedTransaction> lookup(final String originalTransactionId) {
return getAllSubscriptions(originalTransactionId).thenApplyAsync(statuses -> {
private DecodedTransaction lookup(final String originalTransactionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound {
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));
final SubscriptionGroupIdentifierItem item = statuses.getData().stream()
.filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()
.orElseThrow(() -> ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments("transaction did not contain a backup subscription", null)));
final List<DecodedTransaction> txs = item.getLastTransactions().stream()
.map(this::decode)
.filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId()))
.toList();
final List<DecodedTransaction> txs = item.getLastTransactions().stream()
.map(this::decode)
.filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId()))
.toList();
if (txs.isEmpty()) {
throw new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null);
}
if (txs.isEmpty()) {
throw ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null));
}
if (txs.size() > 1) {
logger.warn("Multiple matching product transactions found for transactionId {}, only considering first",
originalTransactionId);
}
if (txs.size() > 1) {
logger.warn("Multiple matching product transactions found for transactionId {}, only considering first",
originalTransactionId);
}
if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) {
// 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 ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments(
"transactionId was not the transaction's originalTransactionId", null));
}
return txs.getFirst();
}, executor).toCompletableFuture();
if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) {
// 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);
}
return txs.getFirst();
}
private CompletionStage<StatusResponse> getAllSubscriptions(final String originalTransactionId) {
Supplier<CompletionStage<StatusResponse>> supplier = () -> CompletableFuture.supplyAsync(() -> {
try {
return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES);
} catch (final APIException e) {
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment();
throw ExceptionUtils.wrap(switch (e.getApiError()) {
case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionException.NotFound();
case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);
case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage());
default -> e;
});
} catch (final IOException e) {
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment();
throw ExceptionUtils.wrap(e);
}
}, executor);
return retry.executeCompletionStage(retryExecutor, supplier);
private StatusResponse getAllSubscriptions(final String originalTransactionId)
throws SubscriptionException.NotFound, SubscriptionException.InvalidArguments, RateLimitExceededException {
try {
return retry.executeCallable(() -> {
try {
return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES);
} 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 RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);
case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage());
default -> e;
};
} catch (final IOException e) {
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment();
throw e;
}
});
} catch (SubscriptionException.NotFound | SubscriptionException.InvalidArguments | RateLimitExceededException e) {
throw e;
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (APIException e) {
throw new UncheckedIOException(new IOException(e));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static boolean shouldRetry(Throwable e) {
return ExceptionUtils.unwrap(e) instanceof APIException apiException && switch (apiException.getApiError()) {
return e instanceof APIException apiException && switch (apiException.getApiError()) {
case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true;
default -> false;
};
@@ -291,7 +279,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()),
signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo()));
} catch (VerificationException e) {
throw ExceptionUtils.wrap(new IOException("Failed to verify payload from App Store Server", e));
throw new UncheckedIOException(new IOException("Failed to verify payload from App Store Server", e));
}
}
@@ -302,12 +290,11 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount));
}
private long getLevel(final DecodedTransaction tx) {
private long getLevel(final DecodedTransaction tx) throws SubscriptionException.InvalidArguments {
final Long level = productIdToLevel.get(tx.transaction.getProductId());
if (level == null) {
throw ExceptionUtils.wrap(
new SubscriptionException.InvalidArguments(
"Transaction for unknown productId " + tx.transaction.getProductId()));
throw new SubscriptionException.InvalidArguments(
"Transaction for unknown productId " + tx.transaction.getProductId());
}
return level;
}

View File

@@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.subscriptions;
import com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation;
import com.braintree.graphql.clientoperation.VaultPaymentMethodMutation;
import com.braintreegateway.BraintreeGateway;
import com.braintreegateway.ClientTokenRequest;
import com.braintreegateway.Customer;
@@ -30,13 +32,11 @@ import java.time.Instant;
import java.util.Collections;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import javax.annotation.Nullable;
@@ -48,6 +48,7 @@ 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;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@@ -81,8 +82,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
final CurrencyConversionManager currencyConversionManager,
final PublisherInterface pubsubPublisher,
@Nullable final String circuitBreakerConfigurationName,
final Executor executor,
final ScheduledExecutorService retryExecutor) {
final Executor executor) {
this(new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey,
braintreePrivateKey),
@@ -317,130 +317,105 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
}
@Override
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {
return CompletableFuture.supplyAsync(() -> {
CustomerRequest request = new CustomerRequest()
.customField("subscriber_user", HexFormat.of().formatHex(subscriberUser));
public ProcessorCustomer createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {
CustomerRequest request = new CustomerRequest()
.customField("subscriber_user", HexFormat.of().formatHex(subscriberUser));
if (clientPlatform != null) {
request.customField("client_platform", clientPlatform.name().toLowerCase());
}
try {
return braintreeGateway.customer().create(request);
} catch (BraintreeException e) {
throw new CompletionException(e);
}
}, executor)
.thenApply(result -> {
if (!result.isSuccess()) {
throw new CompletionException(new BraintreeException(result.getMessage()));
}
return new ProcessorCustomer(result.getTarget().getId(), PaymentProvider.BRAINTREE);
});
if (clientPlatform != null) {
request.customField("client_platform", clientPlatform.name().toLowerCase());
}
final Result<Customer> result = braintreeGateway.customer().create(request);
if (!result.isSuccess()) {
throw new BraintreeException(result.getMessage());
}
return new ProcessorCustomer(result.getTarget().getId(), PaymentProvider.BRAINTREE);
}
@Override
public CompletableFuture<String> createPaymentMethodSetupToken(final String customerId) {
return CompletableFuture.supplyAsync(() -> {
ClientTokenRequest request = new ClientTokenRequest()
.customerId(customerId);
public String createPaymentMethodSetupToken(final String customerId) {
ClientTokenRequest request = new ClientTokenRequest().customerId(customerId);
return braintreeGateway.clientToken().generate(request);
}, executor);
return braintreeGateway.clientToken().generate(request);
}
@Override
public CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken,
public void setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken,
@Nullable String currentSubscriptionId) {
final Optional<String> maybeSubscriptionId = Optional.ofNullable(currentSubscriptionId);
return braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken)
.thenCompose(tokenizePayPalBillingAgreement ->
braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id))
.thenApplyAsync(vaultPaymentMethod -> braintreeGateway.customer()
.update(customerId, new CustomerRequest()
.defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id)),
executor)
.thenAcceptAsync(result -> {
maybeSubscriptionId.ifPresent(
subscriptionId -> braintreeGateway.subscription()
.update(subscriptionId, new SubscriptionRequest()
.paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken())));
}, executor);
final TokenizePayPalBillingAgreementMutation.TokenizePayPalBillingAgreement tokenizePayPalBillingAgreement =
braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken).join();
final VaultPaymentMethodMutation.VaultPaymentMethod vaultPaymentMethod =
braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id).join();
final Result<Customer> result = braintreeGateway.customer()
.update(customerId, new CustomerRequest().defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id));
maybeSubscriptionId.ifPresent(subscriptionId ->
braintreeGateway.subscription().update(subscriptionId, new SubscriptionRequest()
.paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken())));
}
@Override
public CompletableFuture<Object> getSubscription(String subscriptionId) {
return CompletableFuture.supplyAsync(() -> braintreeGateway.subscription().find(subscriptionId), executor);
public Object getSubscription(String subscriptionId) {
return braintreeGateway.subscription().find(subscriptionId);
}
@Override
public CompletableFuture<SubscriptionId> createSubscription(String customerId, String planId, long level,
long lastSubscriptionCreatedAt) {
public SubscriptionId createSubscription(String customerId, String planId, long level,
long lastSubscriptionCreatedAt)
throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException {
return getDefaultPaymentMethod(customerId)
.thenCompose(paymentMethod -> {
if (paymentMethod == null) {
throw ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict());
}
final com.braintreegateway.PaymentMethod paymentMethod = getDefaultPaymentMethod(customerId);
if (paymentMethod == null) {
throw new SubscriptionException.ProcessorConflict();
}
final Optional<Subscription> maybeExistingSubscription = paymentMethod.getSubscriptions().stream()
.filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE))
.filter(Subscription::neverExpires)
.findAny();
final Optional<Subscription> maybeExistingSubscription = paymentMethod.getSubscriptions().stream()
.filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE))
.filter(Subscription::neverExpires)
.findAny();
return maybeExistingSubscription.map(subscription -> findPlan(subscription.getPlanId())
.thenApply(plan -> {
if (getLevelForPlan(plan) != level) {
// if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption)
// with a different level.
// In this case, its safer and easier to recover by returning this subscription, rather than
// returning an error
logger.warn("existing subscription had unexpected level");
}
return subscription;
}))
.orElseGet(() -> findPlan(planId).thenApplyAsync(plan -> {
final Result<Subscription> result = braintreeGateway.subscription().create(new SubscriptionRequest()
.planId(planId)
.paymentMethodToken(paymentMethod.getToken())
.merchantAccountId(
currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT)))
.options()
.startImmediately(true)
.done()
);
if (maybeExistingSubscription.isPresent()) {
final Subscription subscription = maybeExistingSubscription.get();
final Plan plan = findPlan(subscription.getPlanId());
if (getLevelForPlan(plan) != level) {
// if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption)
// with a different level.
// In this case, its safer and easier to recover by returning this subscription, rather than
// returning an error
logger.warn("existing subscription had unexpected level");
}
return new SubscriptionId(subscription.getId());
}
final Plan plan = findPlan(planId);
final Result<Subscription> result = braintreeGateway.subscription().create(new SubscriptionRequest()
.planId(planId)
.paymentMethodToken(paymentMethod.getToken())
.merchantAccountId(
currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT)))
.options()
.startImmediately(true)
.done());
if (!result.isSuccess()) {
final CompletionException completionException;
if (result.getTarget() != null) {
completionException = result.getTarget().getTransactions().stream().findFirst()
.map(transaction -> new CompletionException(
new SubscriptionException.ProcessorException(getProvider(), createChargeFailure(transaction))))
.orElseGet(() -> new CompletionException(new BraintreeException(result.getMessage())));
} else {
completionException = new CompletionException(new BraintreeException(result.getMessage()));
}
if (!result.isSuccess()) {
throw Optional
.ofNullable(result.getTarget())
.flatMap(subscription -> subscription.getTransactions().stream().findFirst())
.map(transaction -> new SubscriptionException.ProcessorException(getProvider(),
createChargeFailure(transaction)))
.orElseThrow(() -> new BraintreeException(result.getMessage()));
}
throw completionException;
}
return result.getTarget();
}));
}).thenApply(subscription -> new SubscriptionId(subscription.getId()));
return new SubscriptionId(result.getTarget().getId());
}
private CompletableFuture<com.braintreegateway.PaymentMethod> getDefaultPaymentMethod(String customerId) {
return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId).getDefaultPaymentMethod(),
executor);
private com.braintreegateway.PaymentMethod getDefaultPaymentMethod(String customerId) {
return braintreeGateway.customer().find(customerId).getDefaultPaymentMethod();
}
@Override
public CompletableFuture<SubscriptionId> updateSubscription(Object subscriptionObj, String planId, long level,
String idempotencyKey) {
public CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updateSubscription(Object subscriptionObj, String planId, long level,
String idempotencyKey) throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException {
if (!(subscriptionObj instanceof final Subscription subscription)) {
throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName());
@@ -449,31 +424,26 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
// not prorated. Braintree subscriptions cannot change their next billing date,
// so we must end the existing one and create a new one
return endSubscription(subscription)
.thenCompose(ignored -> {
endSubscription(subscription);
final Transaction transaction = getLatestTransactionForSubscription(subscription)
.orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict()));
final Transaction transaction = getLatestTransactionForSubscription(subscription)
.orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionException.ProcessorConflict()));
final Customer customer = transaction.getCustomer();
final Customer customer = transaction.getCustomer();
return createSubscription(customer.getId(), planId, level,
subscription.getCreatedAt().toInstant().getEpochSecond());
});
return createSubscription(customer.getId(), planId, level,
subscription.getCreatedAt().toInstant().getEpochSecond());
}
@Override
public CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscriptionObj) {
public LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscriptionObj) {
final Subscription subscription = getSubscription(subscriptionObj);
return findPlan(subscription.getPlanId())
.thenApply(
plan -> new LevelAndCurrency(getLevelForPlan(plan), plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT)));
final Plan plan = findPlan(subscription.getPlanId());
return new LevelAndCurrency(getLevelForPlan(plan), plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT));
}
private CompletableFuture<Plan> findPlan(String planId) {
return CompletableFuture.supplyAsync(() -> braintreeGateway.plan().find(planId), executor);
private Plan findPlan(String planId) {
return braintreeGateway.plan().find(planId);
}
private long getLevelForPlan(final Plan plan) {
@@ -489,37 +459,32 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
return getSubscription(subscriptionId).thenApplyAsync(subscriptionObj -> {
final Subscription subscription = getSubscription(subscriptionObj);
public SubscriptionInformation getSubscriptionInformation(final String subscriptionId) {
final Subscription subscription = getSubscription(getSubscription(subscriptionId));
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
final long level = getLevelForPlan(plan);
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
final Instant anchor = subscription.getFirstBillingDate().toInstant();
final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant();
final long level = getLevelForPlan(plan);
final TransactionInfo latestTransactionInfo = getLatestTransactionForSubscription(subscription)
.map(this::getTransactionInfo)
.orElse(new TransactionInfo(PaymentMethod.PAYPAL, false, false, null));
final Instant anchor = subscription.getFirstBillingDate().toInstant();
final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant();
final TransactionInfo latestTransactionInfo = getLatestTransactionForSubscription(subscription)
.map(this::getTransactionInfo)
.orElse(new TransactionInfo(PaymentMethod.PAYPAL, false, false, null));
return new SubscriptionInformation(
new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT),
SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())),
level,
anchor,
endOfCurrentPeriod,
Subscription.Status.ACTIVE == subscription.getStatus(),
!subscription.neverExpires(),
getSubscriptionStatus(subscription.getStatus(), latestTransactionInfo.transactionFailed()),
PaymentProvider.BRAINTREE,
latestTransactionInfo.paymentMethod(),
latestTransactionInfo.paymentProcessing(),
latestTransactionInfo.chargeFailure()
);
}, executor);
return new SubscriptionInformation(
new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT),
SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())),
level,
anchor,
endOfCurrentPeriod,
Subscription.Status.ACTIVE == subscription.getStatus(),
!subscription.neverExpires(),
getSubscriptionStatus(subscription.getStatus(), latestTransactionInfo.transactionFailed()),
PaymentProvider.BRAINTREE,
latestTransactionInfo.paymentMethod(),
latestTransactionInfo.paymentProcessing(),
latestTransactionInfo.chargeFailure()
);
}
private record TransactionInfo(
@@ -576,76 +541,69 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
}
@Override
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId), executor).thenCompose(customer -> {
final List<CompletableFuture<Void>> subscriptionCancelFutures = Optional.ofNullable(customer.getDefaultPaymentMethod())
.map(com.braintreegateway.PaymentMethod::getSubscriptions)
.orElse(Collections.emptyList())
.stream()
.map(this::endSubscription)
.toList();
return CompletableFuture.allOf(subscriptionCancelFutures.toArray(new CompletableFuture[0]));
});
public void cancelAllActiveSubscriptions(String customerId) {
final Customer customer = braintreeGateway.customer().find(customerId);
ExecutorUtil.runAll(executor, Optional.ofNullable(customer.getDefaultPaymentMethod())
.stream()
.flatMap(paymentMethod -> paymentMethod.getSubscriptions().stream())
.<Runnable>map(subscription -> () -> this.endSubscription(subscription))
.toList());
}
private CompletableFuture<Void> endSubscription(Subscription subscription) {
private void endSubscription(Subscription subscription) {
final boolean latestTransactionFailed = getLatestTransactionForSubscription(subscription)
.map(this::getTransactionInfo)
.map(TransactionInfo::transactionFailed)
.orElse(false);
return switch (getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed)) {
switch (getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed)) {
// The payment for this period has not processed yet, we should immediately cancel to prevent any payment from
// going through.
case INCOMPLETE, PAST_DUE, UNPAID -> cancelSubscriptionImmediately(subscription);
// Otherwise, set the subscription to cancel at the current period end. If the subscription is active, it may
// continue to be used until the end of the period.
default -> cancelSubscriptionAtEndOfCurrentPeriod(subscription);
};
}
}
private CompletableFuture<Void> cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {
return CompletableFuture.runAsync(() -> braintreeGateway
private void cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {
braintreeGateway
.subscription()
.update(subscription.getId(),
new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle())), executor);
new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle()));
}
private CompletableFuture<Void> cancelSubscriptionImmediately(Subscription subscription) {
return CompletableFuture.runAsync(() -> braintreeGateway.subscription().cancel(subscription.getId()), executor);
private void cancelSubscriptionImmediately(Subscription subscription) {
braintreeGateway.subscription().cancel(subscription.getId());
}
@Override
public CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId) {
return getSubscription(subscriptionId)
.thenApply(BraintreeManager::getSubscription)
.thenApply(subscription -> getLatestTransactionForSubscription(subscription)
.map(transaction -> {
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 ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment());
}
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction)));
}
public ReceiptItem getReceiptItem(String subscriptionId)
throws SubscriptionException.ReceiptRequestedForOpenPayment, SubscriptionException.ChargeFailurePaymentRequired {
final Subscription subscription = getSubscription(getSubscription(subscriptionId));
final Transaction transaction = getLatestTransactionForSubscription(subscription)
.orElseThrow(SubscriptionException.ReceiptRequestedForOpenPayment::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 SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction));
}
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();
final Plan plan = braintreeGateway.plan().find(transaction.getPlanId());
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();
final Plan plan = braintreeGateway.plan().find(transaction.getPlanId());
final BraintreePlanMetadata metadata;
try {
metadata = SystemMapper.jsonMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);
final BraintreePlanMetadata metadata;
try {
metadata = SystemMapper.jsonMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return new ReceiptItem(transaction.getId(), PaymentTime.periodStart(paidAt), metadata.level());
})
.orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment())));
return new ReceiptItem(transaction.getId(), PaymentTime.periodStart(paidAt), metadata.level());
}
private static Subscription getSubscription(Object subscriptionObj) {

View File

@@ -8,6 +8,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;
/**
@@ -22,33 +23,72 @@ public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionP
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
/**
* Create a customer on the payment processor
*
* @param subscriberUser An identifier that will be stored with the customer
* @param clientPlatform The {@link ClientPlatform} of the requesting client
* @return A {@link ProcessorCustomer} that can be used to identify this customer on the provider
*/
ProcessorCustomer createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
String createPaymentMethodSetupToken(String customerId);
/**
* @param customerId
* @param paymentMethodToken a processor-specific token necessary
* Set a default payment method
*
* @param customerId The customer to add a default payment method to
* @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
* @return
* @throws SubscriptionException.InvalidArguments If the paymentMethodToken is invalid or the payment method has not
* finished being set up
*/
CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
@Nullable String currentSubscriptionId);
void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
@Nullable String currentSubscriptionId) throws SubscriptionException.InvalidArguments;
CompletableFuture<Object> getSubscription(String subscriptionId);
Object getSubscription(String subscriptionId);
CompletableFuture<SubscriptionId> createSubscription(String customerId, String templateId, long level,
long lastSubscriptionCreatedAt);
/**
* Create a subscription on a customer
*
* @param customerId The customer to create the subscription on
* @param templateId An identifier for the type of subscription to create
* @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
* modified request, or if the payment requires additional steps
* before charging
* @throws SubscriptionException.ProcessorConflict 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;
CompletableFuture<SubscriptionId> updateSubscription(
Object subscription, String templateId, long level, String idempotencyKey);
/**
* Update an existing subscription on a customer
*
* @param subscription The subscription to update
* @param templateId An identifier for the new subscription type
* @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
* modified request, or if the payment requires additional steps
* before charging
* @throws SubscriptionException.ProcessorConflict 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;
/**
* @param subscription
* @return the subscriptions current level and lower-case currency code
*/
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscription);
record SubscriptionId(String id) {

View File

@@ -16,6 +16,7 @@ import com.google.api.services.androidpublisher.model.AutoRenewingPlan;
import com.google.api.services.androidpublisher.model.BasePlan;
import com.google.api.services.androidpublisher.model.OfferDetails;
import com.google.api.services.androidpublisher.model.RegionalBasePlanConfig;
import com.google.api.services.androidpublisher.model.Subscription;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
@@ -27,6 +28,7 @@ import io.micrometer.core.instrument.Tags;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.security.GeneralSecurityException;
import java.time.Clock;
import java.time.Instant;
@@ -37,8 +39,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
@@ -47,7 +47,6 @@ 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.ExceptionUtils;
/**
* Manages subscriptions made with the Play Billing API
@@ -66,7 +65,6 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);
private final AndroidPublisher androidPublisher;
private final Executor executor;
private final String packageName;
private final Map<String, Long> productIdToLevel;
private final Clock clock;
@@ -80,8 +78,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
final InputStream credentialsStream,
final String packageName,
final String applicationName,
final Map<String, Long> productIdToLevel,
final Executor executor)
final Map<String, Long> productIdToLevel)
throws GeneralSecurityException, IOException {
this(new AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
@@ -91,7 +88,7 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
.createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER)))
.setApplicationName(applicationName)
.build(),
Clock.systemUTC(), packageName, productIdToLevel, executor);
Clock.systemUTC(), packageName, productIdToLevel);
}
@VisibleForTesting
@@ -99,12 +96,10 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
final AndroidPublisher androidPublisher,
final Clock clock,
final String packageName,
final Map<String, Long> productIdToLevel,
final Executor executor) {
final Map<String, Long> productIdToLevel) {
this.clock = clock;
this.androidPublisher = androidPublisher;
this.productIdToLevel = productIdToLevel;
this.executor = Objects.requireNonNull(executor);
this.packageName = packageName;
}
@@ -138,12 +133,13 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
*
* @return A stage that completes when the purchase has been successfully acknowledged
*/
public CompletableFuture<Void> acknowledgePurchase() {
public void acknowledgePurchase()
throws RateLimitExceededException, SubscriptionException.NotFound {
if (!requiresAck) {
// We've already acknowledged this purchase on a previous attempt, nothing to do
return CompletableFuture.completedFuture(null);
return;
}
return executeTokenOperation(pub -> pub.purchases().subscriptions()
executeTokenOperation(pub -> pub.purchases().subscriptions()
.acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest()));
}
@@ -157,45 +153,47 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
* then acknowledged with {@link ValidatedToken#acknowledgePurchase()}
*
* @param purchaseToken The play store billing purchaseToken that represents a subscription purchase
* @return A stage that completes successfully when the token has been validated, or fails if the token does not
* represent an active 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
* user an entitlement
*/
public CompletableFuture<ValidatedToken> validateToken(String purchaseToken) {
return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
public ValidatedToken validateToken(String purchaseToken)
throws RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired {
final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);
final SubscriptionState state = SubscriptionState
.fromString(subscription.getSubscriptionState())
.orElse(SubscriptionState.UNSPECIFIED);
final SubscriptionState state = SubscriptionState
.fromString(subscription.getSubscriptionState())
.orElse(SubscriptionState.UNSPECIFIED);
Metrics.counter(VALIDATE_COUNTER_NAME, subscriptionTags(subscription)).increment();
Metrics.counter(VALIDATE_COUNTER_NAME, subscriptionTags(subscription)).increment();
// We only accept tokens in a state where the user may be entitled to their purchase. This is true even in the
// CANCELLED state. For example, a user may subscribe for 1 month, then immediately cancel (disabling auto-renew)
// and then submit their token. In this case they should still be able to retrieve their entitlement.
// See https://developer.android.com/google/play/billing/integrate#life
if (state != SubscriptionState.ACTIVE
&& state != SubscriptionState.IN_GRACE_PERIOD
&& state != SubscriptionState.CANCELED) {
throw new SubscriptionException.PaymentRequired(
"Cannot acknowledge purchase for subscription in state " + subscription.getSubscriptionState());
}
// We only accept tokens in a state where the user may be entitled to their purchase. This is true even in the
// CANCELLED state. For example, a user may subscribe for 1 month, then immediately cancel (disabling auto-renew)
// and then submit their token. In this case they should still be able to retrieve their entitlement.
// See https://developer.android.com/google/play/billing/integrate#life
if (state != SubscriptionState.ACTIVE
&& state != SubscriptionState.IN_GRACE_PERIOD
&& state != SubscriptionState.CANCELED) {
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired(
"Cannot acknowledge purchase for subscription in state " + subscription.getSubscriptionState()));
}
final AcknowledgementState acknowledgementState = AcknowledgementState
.fromString(subscription.getAcknowledgementState())
.orElse(AcknowledgementState.UNSPECIFIED);
final AcknowledgementState acknowledgementState = AcknowledgementState
.fromString(subscription.getAcknowledgementState())
.orElse(AcknowledgementState.UNSPECIFIED);
final boolean requiresAck = switch (acknowledgementState) {
case ACKNOWLEDGED -> false;
case PENDING -> true;
case UNSPECIFIED -> throw new UncheckedIOException(
new IOException("Invalid acknowledgement state " + subscription.getAcknowledgementState()));
};
final boolean requiresAck = switch (acknowledgementState) {
case ACKNOWLEDGED -> false;
case PENDING -> true;
case UNSPECIFIED -> throw ExceptionUtils.wrap(
new IOException("Invalid acknowledgement state " + subscription.getAcknowledgementState()));
};
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
final long level = productIdToLevel(purchase.getProductId());
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
final long level = productIdToLevel(purchase.getProductId());
return new ValidatedToken(level, purchase.getProductId(), purchaseToken, requiresAck);
}, executor);
return new ValidatedToken(level, purchase.getProductId(), purchaseToken, requiresAck);
}
@@ -204,10 +202,11 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
* entitlement until their current period expires.
*
* @param purchaseToken The purchaseToken associated with the subscription
* @return A stage that completes when the subscription has successfully been cancelled
* @throws RateLimitExceededException If rate-limited by play-billing
*/
public CompletableFuture<Void> cancelAllActiveSubscriptions(String purchaseToken) {
return lookupSubscription(purchaseToken).thenCompose(subscription -> {
public void cancelAllActiveSubscriptions(String purchaseToken) throws RateLimitExceededException {
try {
final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);
Metrics.counter(CANCEL_COUNTER_NAME, subscriptionTags(subscription)).increment();
final SubscriptionState state = SubscriptionState
@@ -216,118 +215,119 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
if (state == SubscriptionState.CANCELED || state == SubscriptionState.EXPIRED) {
// already cancelled, nothing to do
return CompletableFuture.completedFuture(null);
return;
}
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
return executeTokenOperation(pub ->
executeTokenOperation(pub ->
pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken));
})
// If the subscription is not found, no need to do anything
.exceptionally(ExceptionUtils.exceptionallyHandler(SubscriptionException.NotFound.class, e -> null));
} catch (SubscriptionException.NotFound e) {
// If the subscription is not found, no need to do anything so we can squash it
}
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String purchaseToken) {
public SubscriptionInformation getSubscriptionInformation(final String purchaseToken)
throws RateLimitExceededException, SubscriptionException.NotFound {
final CompletableFuture<SubscriptionPurchaseV2> subscriptionFuture = lookupSubscription(purchaseToken);
final CompletableFuture<SubscriptionPrice> priceFuture = subscriptionFuture.thenCompose(this::getSubscriptionPrice);
final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);
final SubscriptionPrice price = getSubscriptionPrice(subscription);
return subscriptionFuture.thenCombineAsync(priceFuture, (subscription, price) -> {
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription);
final Optional<Instant> billingCycleAnchor = getStartTime(subscription);
final Optional<Instant> expiration = getExpiration(lineItem);
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription);
final Optional<Instant> billingCycleAnchor = getStartTime(subscription);
final Optional<Instant> expiration = getExpiration(lineItem);
final SubscriptionStatus status = switch (SubscriptionState
.fromString(subscription.getSubscriptionState())
.orElse(SubscriptionState.UNSPECIFIED)) {
// In play terminology CANCELLED is the same as an active subscription with cancelAtPeriodEnd set in Stripe. So
// it should map to the ACTIVE stripe status.
case ACTIVE, CANCELED -> SubscriptionStatus.ACTIVE;
case PENDING -> SubscriptionStatus.INCOMPLETE;
case ON_HOLD, PAUSED -> SubscriptionStatus.PAST_DUE;
case IN_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
// EXPIRED is the equivalent of a Stripe CANCELLED subscription
case EXPIRED, PENDING_PURCHASE_CANCELED -> SubscriptionStatus.CANCELED;
case UNSPECIFIED -> SubscriptionStatus.UNKNOWN;
};
final SubscriptionStatus status = switch (SubscriptionState
.fromString(subscription.getSubscriptionState())
.orElse(SubscriptionState.UNSPECIFIED)) {
// In play terminology CANCELLED is the same as an active subscription with cancelAtPeriodEnd set in Stripe. So
// it should map to the ACTIVE stripe status.
case ACTIVE, CANCELED -> SubscriptionStatus.ACTIVE;
case PENDING -> SubscriptionStatus.INCOMPLETE;
case ON_HOLD, PAUSED -> SubscriptionStatus.PAST_DUE;
case IN_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
// EXPIRED is the equivalent of a Stripe CANCELLED subscription
case EXPIRED, PENDING_PURCHASE_CANCELED -> SubscriptionStatus.CANCELED;
case UNSPECIFIED -> SubscriptionStatus.UNKNOWN;
};
final boolean autoRenewEnabled = Optional
.ofNullable(lineItem.getAutoRenewingPlan())
.map(AutoRenewingPlan::getAutoRenewEnabled) // returns null or false if auto-renew disabled
.orElse(false);
return new SubscriptionInformation(
price,
productIdToLevel(lineItem.getProductId()),
billingCycleAnchor.orElse(null),
expiration.orElse(null),
expiration.map(clock.instant()::isBefore).orElse(false),
!autoRenewEnabled,
status,
PaymentProvider.GOOGLE_PLAY_BILLING,
PaymentMethod.GOOGLE_PLAY_BILLING,
false,
null);
}, executor);
final boolean autoRenewEnabled = Optional
.ofNullable(lineItem.getAutoRenewingPlan())
.map(AutoRenewingPlan::getAutoRenewEnabled) // returns null or false if auto-renew disabled
.orElse(false);
return new SubscriptionInformation(
price,
productIdToLevel(lineItem.getProductId()),
billingCycleAnchor.orElse(null),
expiration.orElse(null),
expiration.map(clock.instant()::isBefore).orElse(false),
!autoRenewEnabled,
status,
PaymentProvider.GOOGLE_PLAY_BILLING,
PaymentMethod.GOOGLE_PLAY_BILLING,
false,
null);
}
private CompletableFuture<SubscriptionPrice> getSubscriptionPrice(final SubscriptionPurchaseV2 subscriptionPurchase) {
private SubscriptionPrice getSubscriptionPrice(final SubscriptionPurchaseV2 subscriptionPurchase) {
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscriptionPurchase);
final OfferDetails offerDetails = lineItem.getOfferDetails();
final String basePlanId = offerDetails.getBasePlanId();
return this.executeAsync(pub -> pub.monetization().subscriptions().get(packageName, lineItem.getProductId()))
.thenApplyAsync(subscription -> {
try {
final Subscription subscription = this.androidPublisher.monetization().subscriptions()
.get(packageName, lineItem.getProductId()).execute();
final BasePlan basePlan = subscription.getBasePlans().stream()
.filter(bp -> bp.getBasePlanId().equals(basePlanId))
.findFirst()
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown basePlanId " + basePlanId)));
final String region = subscriptionPurchase.getRegionCode();
final RegionalBasePlanConfig basePlanConfig = basePlan.getRegionalConfigs()
.stream()
.filter(rbpc -> Objects.equals(region, rbpc.getRegionCode()))
.findFirst()
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown subscription region " + region)));
final BasePlan basePlan = subscription.getBasePlans().stream()
.filter(bp -> bp.getBasePlanId().equals(basePlanId))
.findFirst()
.orElseThrow(() -> new UncheckedIOException(new IOException("unknown basePlanId " + basePlanId)));
final String region = subscriptionPurchase.getRegionCode();
final RegionalBasePlanConfig basePlanConfig = basePlan.getRegionalConfigs()
.stream()
.filter(rbpc -> Objects.equals(region, rbpc.getRegionCode()))
.findFirst()
.orElseThrow(() -> new UncheckedIOException(new IOException("unknown subscription region " + region)));
return new SubscriptionPrice(
basePlanConfig.getPrice().getCurrencyCode().toUpperCase(Locale.ROOT),
SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(basePlanConfig.getPrice()));
}, executor);
return new SubscriptionPrice(
basePlanConfig.getPrice().getCurrencyCode().toUpperCase(Locale.ROOT),
SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(basePlanConfig.getPrice()));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public CompletableFuture<ReceiptItem> getReceiptItem(String purchaseToken) {
return lookupSubscription(purchaseToken).thenApplyAsync(subscription -> {
final AcknowledgementState acknowledgementState = AcknowledgementState
.fromString(subscription.getAcknowledgementState())
.orElse(AcknowledgementState.UNSPECIFIED);
if (acknowledgementState != AcknowledgementState.ACKNOWLEDGED) {
// We should only ever generate receipts for a stored and acknowledged token.
logger.error("Tried to fetch receipt for purchaseToken {} that was never acknowledged", purchaseToken);
throw new IllegalStateException("Tried to fetch receipt for purchaseToken that was never acknowledged");
}
public ReceiptItem getReceiptItem(String purchaseToken)
throws RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.PaymentRequired {
final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);
final AcknowledgementState acknowledgementState = AcknowledgementState
.fromString(subscription.getAcknowledgementState())
.orElse(AcknowledgementState.UNSPECIFIED);
if (acknowledgementState != AcknowledgementState.ACKNOWLEDGED) {
// We should only ever generate receipts for a stored and acknowledged token.
logger.error("Tried to fetch receipt for purchaseToken {} that was never acknowledged", purchaseToken);
throw new IllegalStateException("Tried to fetch receipt for purchaseToken that was never acknowledged");
}
Metrics.counter(GET_RECEIPT_COUNTER_NAME, subscriptionTags(subscription)).increment();
Metrics.counter(GET_RECEIPT_COUNTER_NAME, subscriptionTags(subscription)).increment();
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
final Instant expiration = getExpiration(purchase)
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("Invalid subscription expiration")));
final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);
final Instant expiration = getExpiration(purchase)
.orElseThrow(() -> new UncheckedIOException(new IOException("Invalid subscription expiration")));
if (expiration.isBefore(clock.instant())) {
// 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 ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
}
if (expiration.isBefore(clock.instant())) {
// 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();
}
return new ReceiptItem(
subscription.getLatestOrderId(),
PaymentTime.periodEnds(expiration),
productIdToLevel(purchase.getProductId()));
}, executor);
return new ReceiptItem(
subscription.getLatestOrderId(),
PaymentTime.periodEnds(expiration),
productIdToLevel(purchase.getProductId()));
}
@@ -336,23 +336,6 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
AndroidPublisherRequest<T> req(AndroidPublisher publisher) throws IOException;
}
/**
* Asynchronously execute a synchronous API call from an AndroidPublisher
*
* @param apiCall A function that takes the publisher and returns the API call to execute
* @param <R> The return type of the executed ApiCall
* @return A stage that completes with the result of the API call
*/
private <R> CompletableFuture<R> executeAsync(final ApiCall<R> apiCall) {
return CompletableFuture.supplyAsync(() -> {
try {
return apiCall.req(androidPublisher).execute();
} catch (IOException e) {
throw ExceptionUtils.wrap(e);
}
}, executor);
}
/**
* Asynchronously execute a synchronous API call on a purchaseToken, mapping expected errors to the appropriate
* {@link SubscriptionException}
@@ -361,26 +344,31 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
* @param <R> The result of the API call
* @return A stage that completes with the result of the API call
*/
private <R> CompletableFuture<R> executeTokenOperation(final ApiCall<R> apiCall) {
return executeAsync(apiCall)
.exceptionally(ExceptionUtils.exceptionallyHandler(HttpResponseException.class, e -> {
if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()
|| e.getStatusCode() == Response.Status.GONE.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {
throw ExceptionUtils.wrap(new RateLimitExceededException(null));
}
final String details = e instanceof GoogleJsonResponseException
? ((GoogleJsonResponseException) e).getDetails().toString()
: "";
private <R> R executeTokenOperation(final ApiCall<R> apiCall)
throws RateLimitExceededException, SubscriptionException.NotFound {
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();
}
if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {
throw new RateLimitExceededException(null);
}
final String details = e instanceof GoogleJsonResponseException
? ((GoogleJsonResponseException) e).getDetails().toString()
: "";
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details);
throw ExceptionUtils.wrap(e);
}));
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details);
throw new UncheckedIOException(e);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private CompletableFuture<SubscriptionPurchaseV2> lookupSubscription(final String purchaseToken) {
private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken)
throws RateLimitExceededException, SubscriptionException.NotFound {
return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));
}

View File

@@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.subscriptions;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.stripe.Stripe;
@@ -44,6 +45,7 @@ import com.stripe.param.SubscriptionUpdateParams;
import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;
import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
@@ -59,11 +61,11 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
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;
@@ -77,6 +79,7 @@ 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;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor {
@@ -90,19 +93,16 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
private final String boostDescription;
private final Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod;
public StripeManager(
@Nonnull String apiKey,
@VisibleForTesting
StripeManager(
@Nonnull StripeClient stripeClient,
@Nonnull Executor executor,
@Nonnull byte[] idempotencyKeyGenerator,
@Nonnull String boostDescription,
@Nonnull Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod) {
if (Strings.isNullOrEmpty(apiKey)) {
throw new IllegalArgumentException("apiKey cannot be empty");
}
Stripe.setAppInfo("Signal-Server", WhisperServerVersion.getServerVersion());
this.stripeClient = new StripeClient(apiKey);
this.stripeClient = Objects.requireNonNull(stripeClient);
this.executor = Objects.requireNonNull(executor);
this.idempotencyKeyGenerator = Objects.requireNonNull(idempotencyKeyGenerator);
if (idempotencyKeyGenerator.length == 0) {
@@ -111,6 +111,18 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
this.boostDescription = Objects.requireNonNull(boostDescription);
this.supportedCurrenciesByPaymentMethod = supportedCurrenciesByPaymentMethod;
}
public StripeManager(
@Nonnull String apiKey,
@Nonnull Executor executor,
@Nonnull byte[] idempotencyKeyGenerator,
@Nonnull String boostDescription,
@Nonnull Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod) {
this(new StripeClient(apiKey), executor, idempotencyKeyGenerator, boostDescription, supportedCurrenciesByPaymentMethod);
if (Strings.isNullOrEmpty(apiKey)) {
throw new IllegalArgumentException("apiKey cannot be empty");
}
}
@Override
public PaymentProvider getProvider() {
@@ -135,40 +147,35 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
@Override
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {
return CompletableFuture.supplyAsync(() -> {
final CustomerCreateParams.Builder builder = CustomerCreateParams.builder()
.putMetadata("subscriberUser", HexFormat.of().formatHex(subscriberUser));
public ProcessorCustomer createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {
final CustomerCreateParams.Builder builder = CustomerCreateParams.builder()
.putMetadata("subscriberUser", HexFormat.of().formatHex(subscriberUser));
if (clientPlatform != null) {
builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase());
}
if (clientPlatform != null) {
builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase());
}
try {
return stripeClient.customers()
.create(builder.build(), commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor)
.thenApply(customer -> new ProcessorCustomer(customer.getId(), getProvider()));
try {
final Customer customer = stripeClient.customers()
.create(builder.build(), commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
return new ProcessorCustomer(customer.getId(), getProvider());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
public CompletableFuture<Customer> getCustomer(String customerId) {
return CompletableFuture.supplyAsync(() -> {
CustomerRetrieveParams params = CustomerRetrieveParams.builder().build();
try {
return stripeClient.customers().retrieve(customerId, params, commonOptions());
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
public Customer getCustomer(String customerId) {
CustomerRetrieveParams params = CustomerRetrieveParams.builder().build();
try {
return stripeClient.customers().retrieve(customerId, params, commonOptions());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
@Override
public CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId,
@Nullable String currentSubscriptionId) {
return CompletableFuture.supplyAsync(() -> {
public void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId,
@Nullable String currentSubscriptionId) throws SubscriptionException.InvalidArguments {
CustomerUpdateParams params = CustomerUpdateParams.builder()
.setInvoiceSettings(InvoiceSettings.builder()
.setDefaultPaymentMethod(paymentMethodId)
@@ -176,29 +183,24 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
.build();
try {
stripeClient.customers().update(customerId, params, commonOptions());
return null;
} catch (InvalidRequestException e) {
// Could happen if the paymentMethodId was bunk or the client didn't actually finish setting it up
throw ExceptionUtils.wrap(new SubscriptionException.InvalidArguments(e.getMessage()));
throw new SubscriptionException.InvalidArguments(e.getMessage());
} catch (StripeException e) {
throw new CompletionException(e);
throw new UncheckedIOException(new IOException(e));
}
}, executor);
}
@Override
public CompletableFuture<String> createPaymentMethodSetupToken(String customerId) {
return CompletableFuture.supplyAsync(() -> {
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
.setCustomer(customerId)
.build();
try {
return stripeClient.setupIntents().create(params, commonOptions());
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor)
.thenApply(SetupIntent::getClientSecret);
public String createPaymentMethodSetupToken(String customerId) {
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
.setCustomer(customerId)
.build();
try {
return stripeClient.setupIntents().create(params, commonOptions()).getClientSecret();
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
@Override
@@ -282,43 +284,44 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
@Override
public CompletableFuture<SubscriptionId> createSubscription(String customerId, String priceId, long level,
long lastSubscriptionCreatedAt) {
public SubscriptionId createSubscription(String customerId, String priceId, long level,
long lastSubscriptionCreatedAt)
throws SubscriptionException.ProcessorException, SubscriptionException.InvalidArguments {
// this relies on Stripe's idempotency key to avoid creating more than one subscription if the client
// retries this request
return CompletableFuture.supplyAsync(() -> {
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
.setCustomer(customerId)
.setOffSession(true)
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
.addItem(SubscriptionCreateParams.Item.builder()
.setPrice(priceId)
.build())
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
.build();
try {
// the idempotency key intentionally excludes priceId
//
// If the client tells the server several times in a row before the initial creation of a subscription to
// create a subscription, we want to ensure only one gets created.
return stripeClient.subscriptions()
.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription(
customerId, lastSubscriptionCreatedAt)));
} catch (IdempotencyException e) {
throw ExceptionUtils.wrap(new SubscriptionException.InvalidArguments(e.getStripeError().getMessage()));
} catch (CardException e) {
throw new CompletionException(
new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e)));
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor)
.thenApply(subscription -> new SubscriptionId(subscription.getId()));
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
.setCustomer(customerId)
.setOffSession(true)
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
.addItem(SubscriptionCreateParams.Item.builder()
.setPrice(priceId)
.build())
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
.build();
try {
// the idempotency key intentionally excludes priceId
//
// If the client tells the server several times in a row before the initial creation of a subscription to
// create a subscription, we want to ensure only one gets created.
final Subscription subscription = stripeClient.subscriptions().create(
params,
commonOptions(generateIdempotencyKeyForCreateSubscription(customerId, lastSubscriptionCreatedAt)));
return new SubscriptionId(subscription.getId());
} catch (IdempotencyException e) {
throw new SubscriptionException.InvalidArguments(e.getStripeError().getMessage());
} catch (CardException e) {
throw new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e));
} catch (StripeException e) {
if ("subscription_payment_intent_requires_action".equals(e.getCode())) {
throw new SubscriptionException.PaymentRequiresAction();
}
throw new UncheckedIOException(new IOException(e));
}
}
@Override
public CompletableFuture<SubscriptionId> updateSubscription(
Object subscriptionObj, String priceId, long level, String idempotencyKey) {
public SubscriptionId updateSubscription(Object subscriptionObj, String priceId, long level, String idempotencyKey)
throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException {
final Subscription subscription = getSubscription(subscriptionObj);
@@ -328,101 +331,92 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
return createSubscription(subscription.getCustomer(), priceId, level, subscription.getCreated());
}
return CompletableFuture.supplyAsync(() -> {
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
try {
final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.subscriptionItems()
.list(SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(),
commonOptions());
for (final SubscriptionItem item : subscriptionItems.autoPagingIterable()) {
items.add(SubscriptionUpdateParams.Item.builder()
.setId(item.getId())
.setDeleted(true)
.build());
}
items.add(SubscriptionUpdateParams.Item.builder()
.setPrice(priceId)
.build());
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
// not prorated
.setProrationBehavior(ProrationBehavior.NONE)
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
.setOffSession(true)
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
.addAllItem(items)
.build();
return stripeClient.subscriptions().update(subscription.getId(), params,
commonOptions(
generateIdempotencyKeyForSubscriptionUpdate(subscription.getCustomer(), idempotencyKey)));
} catch (IdempotencyException e) {
throw ExceptionUtils.wrap(new SubscriptionException.InvalidArguments(e.getStripeError().getMessage()));
} catch (CardException e) {
throw ExceptionUtils.wrap(
new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e)));
} catch (StripeException e) {
throw ExceptionUtils.wrap(e);
}
}, executor)
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
}
@Override
public CompletableFuture<Object> getSubscription(String subscriptionId) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
.addExpand("latest_invoice")
.addExpand("latest_invoice.charge")
.build();
try {
return stripeClient.subscriptions().retrieve(subscriptionId, params, commonOptions());
} catch (StripeException e) {
throw new CompletionException(e);
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
try {
final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.subscriptionItems()
.list(SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(),
commonOptions());
for (final SubscriptionItem item : subscriptionItems.autoPagingIterable()) {
items.add(SubscriptionUpdateParams.Item.builder()
.setId(item.getId())
.setDeleted(true)
.build());
}
}, executor);
items.add(SubscriptionUpdateParams.Item.builder()
.setPrice(priceId)
.build());
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
// not prorated
.setProrationBehavior(ProrationBehavior.NONE)
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
.setOffSession(true)
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
.addAllItem(items)
.build();
final Subscription subscription1 = stripeClient.subscriptions().update(subscription.getId(), params,
commonOptions(generateIdempotencyKeyForSubscriptionUpdate(subscription.getCustomer(), idempotencyKey)));
return new SubscriptionId(subscription1.getId());
} catch (IdempotencyException e) {
throw new SubscriptionException.InvalidArguments(e.getStripeError().getMessage());
} catch (CardException e) {
throw new SubscriptionException.ProcessorException(getProvider(), createChargeFailureFromCardException(e));
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
@Override
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
return getCustomer(customerId).thenCompose(customer -> {
public Object getSubscription(String subscriptionId) {
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
.addExpand("latest_invoice")
.addExpand("latest_invoice.charge")
.build();
try {
return stripeClient.subscriptions().retrieve(subscriptionId, params, commonOptions());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
@Override
public void cancelAllActiveSubscriptions(String customerId) {
final Customer customer = getCustomer(customerId);
if (customer == null) {
throw ExceptionUtils.wrap(new IOException("no customer record found for id " + customerId));
throw new UncheckedIOException(new IOException("no customer record found for id " + customerId));
}
if (StringUtils.isBlank(customer.getId()) || (!customer.getId().equals(customerId))) {
logger.error("customer ID returned by Stripe ({}) did not match query ({})", customerId, customer.getSubscriptions());
throw ExceptionUtils.wrap(new IOException("unexpected customer ID returned by Stripe"));
throw new UncheckedIOException(new IOException("unexpected customer ID returned by Stripe"));
}
return listNonCanceledSubscriptions(customer);
}).thenCompose(subscriptions -> {
if (subscriptions.stream()
.anyMatch(subscription -> !subscription.getCustomer().equals(customerId))) {
logger.error("Subscription did not match expected customer ID: {}", customerId);
throw ExceptionUtils.wrap( new IOException("mismatched customer ID"));
}
@SuppressWarnings("unchecked")
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
.map(this::endSubscription).toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(futures);
});
final Collection<Subscription> subscriptions = listNonCanceledSubscriptions(customer);
if (subscriptions.stream()
.anyMatch(subscription -> !subscription.getCustomer().equals(customerId))) {
logger.error("Subscription did not match expected customer ID: {}", customerId);
throw new UncheckedIOException(new IOException("mismatched customer ID"));
}
ExecutorUtil.runAll(executor, subscriptions
.stream()
.<Runnable>map(subscription -> () -> this.endSubscription(subscription))
.toList());
}
public CompletableFuture<Collection<Subscription>> listNonCanceledSubscriptions(Customer customer) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionListParams params = SubscriptionListParams.builder()
.setCustomer(customer.getId())
.build();
try {
return Lists.newArrayList(
stripeClient.subscriptions().list(params, commonOptions()).autoPagingIterable());
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
public Collection<Subscription> listNonCanceledSubscriptions(Customer customer) {
SubscriptionListParams params = SubscriptionListParams.builder()
.setCustomer(customer.getId())
.build();
try {
return Lists.newArrayList(
stripeClient.subscriptions().list(params, commonOptions()).autoPagingIterable());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
private CompletableFuture<Subscription> endSubscription(Subscription subscription) {
private Subscription endSubscription(Subscription subscription) {
final SubscriptionStatus status = SubscriptionStatus.forApiValue(subscription.getStatus());
return switch (status) {
// The payment for this period has not processed yet, we should immediately cancel to prevent any payment from
@@ -434,84 +428,74 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
};
}
private CompletableFuture<Subscription> cancelSubscriptionImmediately(Subscription subscription) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionCancelParams params = SubscriptionCancelParams.builder().build();
try {
return stripeClient.subscriptions().cancel(subscription.getId(), params, commonOptions());
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
private Subscription cancelSubscriptionImmediately(Subscription subscription) {
SubscriptionCancelParams params = SubscriptionCancelParams.builder().build();
try {
return stripeClient.subscriptions().cancel(subscription.getId(), params, commonOptions());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
private CompletableFuture<Subscription> cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
.setCancelAtPeriodEnd(true)
.build();
try {
return stripeClient.subscriptions().update(subscription.getId(), params, commonOptions());
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
private Subscription cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
.setCancelAtPeriodEnd(true)
.build();
try {
return stripeClient.subscriptions().update(subscription.getId(), params, commonOptions());
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
public CompletableFuture<Collection<SubscriptionItem>> getItemsForSubscription(Subscription subscription) {
return CompletableFuture.supplyAsync(
() -> {
try {
final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.subscriptionItems().list(
SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(), commonOptions());
return Lists.newArrayList(subscriptionItems.autoPagingIterable());
public Collection<SubscriptionItem> getItemsForSubscription(Subscription subscription) {
try {
final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.subscriptionItems().list(
SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(), commonOptions());
return Lists.newArrayList(subscriptionItems.autoPagingIterable());
} catch (final StripeException e) {
throw new CompletionException(e);
}
},
executor);
} catch (final StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
public CompletableFuture<Price> getPriceForSubscription(Subscription subscription) {
return getItemsForSubscription(subscription).thenApply(subscriptionItems -> {
if (subscriptionItems.isEmpty()) {
throw new IllegalStateException("no items found in subscription " + subscription.getId());
} else if (subscriptionItems.size() > 1) {
throw new IllegalStateException(
"too many items found in subscription " + subscription.getId() + "; items=" + subscriptionItems.size());
} else {
return subscriptionItems.stream().findAny().get().getPrice();
}
});
public Price getPriceForSubscription(Subscription subscription) {
final Collection<SubscriptionItem> subscriptionItems = getItemsForSubscription(subscription);
if (subscriptionItems.isEmpty()) {
throw new IllegalStateException("no items found in subscription " + subscription.getId());
} else if (subscriptionItems.size() > 1) {
throw new IllegalStateException(
"too many items found in subscription " + subscription.getId() + "; items=" + subscriptionItems.size());
} else {
return subscriptionItems.stream().findAny().get().getPrice();
}
}
private CompletableFuture<Product> getProductForSubscription(Subscription subscription) {
return getPriceForSubscription(subscription).thenCompose(price -> getProductForPrice(price.getId()));
private Product getProductForSubscription(Subscription subscription) {
return getProductForPrice(getPriceForSubscription(subscription).getId());
}
@Override
public CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscriptionObj) {
public LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscriptionObj) {
final Subscription subscription = getSubscription(subscriptionObj);
return getProductForSubscription(subscription).thenApply(
product -> new LevelAndCurrency(getLevelForProduct(product), subscription.getCurrency().toLowerCase(
Locale.ROOT)));
final Product product = getProductForSubscription(subscription);
return new LevelAndCurrency(
getLevelForProduct(product),
subscription.getCurrency().toLowerCase(Locale.ROOT));
}
public CompletableFuture<Long> getLevelForPrice(Price price) {
return getProductForPrice(price.getId()).thenApply(this::getLevelForProduct);
public long getLevelForPrice(Price price) {
return getLevelForProduct(getProductForPrice(price.getId()));
}
public CompletableFuture<Product> getProductForPrice(String priceId) {
return CompletableFuture.supplyAsync(() -> {
PriceRetrieveParams params = PriceRetrieveParams.builder().addExpand("product").build();
try {
return stripeClient.prices().retrieve(priceId, params, commonOptions()).getProductObject();
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
public Product getProductForPrice(String priceId) {
PriceRetrieveParams params = PriceRetrieveParams.builder().addExpand("product").build();
try {
return stripeClient.prices().retrieve(priceId, params, commonOptions()).getProductObject();
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
public long getLevelForProduct(Product product) {
@@ -522,24 +506,22 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
* Returns the paid invoices within the past 90 days for a subscription ordered by the creation date in descending
* order (latest first).
*/
public CompletableFuture<Collection<Invoice>> getPaidInvoicesForSubscription(String subscriptionId, Instant now) {
return CompletableFuture.supplyAsync(() -> {
InvoiceListParams params = InvoiceListParams.builder()
.setSubscription(subscriptionId)
.setStatus(InvoiceListParams.Status.PAID)
.setCreated(InvoiceListParams.Created.builder()
.setGte(now.minus(Duration.ofDays(90)).getEpochSecond())
.build())
.build();
try {
ArrayList<Invoice> invoices = Lists.newArrayList(stripeClient.invoices().list(params, commonOptions())
.autoPagingIterable());
invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed());
return invoices;
} catch (StripeException e) {
throw new CompletionException(e);
}
}, executor);
public Collection<Invoice> getPaidInvoicesForSubscription(String subscriptionId, Instant now) {
InvoiceListParams params = InvoiceListParams.builder()
.setSubscription(subscriptionId)
.setStatus(InvoiceListParams.Status.PAID)
.setCreated(InvoiceListParams.Created.builder()
.setGte(now.minus(Duration.ofDays(90)).getEpochSecond())
.build())
.build();
try {
ArrayList<Invoice> invoices = Lists.newArrayList(stripeClient.invoices().list(params, commonOptions())
.autoPagingIterable());
invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed());
return invoices;
} catch (StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
private static ChargeFailure createChargeFailure(final Charge charge) {
@@ -563,45 +545,45 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
@Override
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
return getSubscription(subscriptionId).thenApply(this::getSubscription).thenCompose(subscription ->
getPriceForSubscription(subscription).thenCompose(price ->
getLevelForPrice(price).thenApply(level -> {
ChargeFailure chargeFailure = null;
boolean paymentProcessing = false;
PaymentMethod paymentMethod = null;
public SubscriptionInformation getSubscriptionInformation(final String subscriptionId) {
final Subscription subscription = getSubscription(getSubscription(subscriptionId));
final Price price = getPriceForSubscription(subscription);
final long level = getLevelForPrice(price);
if (subscription.getLatestInvoiceObject() != null) {
final Invoice invoice = subscription.getLatestInvoiceObject();
paymentProcessing = "open".equals(invoice.getStatus());
ChargeFailure chargeFailure = null;
boolean paymentProcessing = false;
PaymentMethod paymentMethod = null;
if (invoice.getChargeObject() != null) {
final Charge charge = invoice.getChargeObject();
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
chargeFailure = createChargeFailure(charge);
}
if (subscription.getLatestInvoiceObject() != null) {
final Invoice invoice = subscription.getLatestInvoiceObject();
paymentProcessing = "open".equals(invoice.getStatus());
if (charge.getPaymentMethodDetails() != null
&& charge.getPaymentMethodDetails().getType() != null) {
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
}
}
}
if (invoice.getChargeObject() != null) {
final Charge charge = invoice.getChargeObject();
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
chargeFailure = createChargeFailure(charge);
}
return new SubscriptionInformation(
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
level,
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
Objects.equals(subscription.getStatus(), "active"),
subscription.getCancelAtPeriodEnd(),
getSubscriptionStatus(subscription.getStatus()),
PaymentProvider.STRIPE,
paymentMethod,
paymentProcessing,
chargeFailure
);
})));
if (charge.getPaymentMethodDetails() != null
&& charge.getPaymentMethodDetails().getType() != null) {
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
}
}
}
return new SubscriptionInformation(
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
level,
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
Objects.equals(subscription.getStatus(), "active"),
subscription.getCancelAtPeriodEnd(),
getSubscriptionStatus(subscription.getStatus()),
PaymentProvider.STRIPE,
paymentMethod,
paymentProcessing,
chargeFailure
);
}
private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) {
@@ -624,79 +606,73 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
@Override
public CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId) {
return getSubscription(subscriptionId)
.thenApply(stripeSubscription -> getSubscription(stripeSubscription).getLatestInvoiceObject())
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, subscriptionId));
public ReceiptItem getReceiptItem(String subscriptionId)
throws SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment {
final Invoice invoice = getSubscription(getSubscription(subscriptionId)).getLatestInvoiceObject();
return convertInvoiceToReceipt(invoice, subscriptionId);
}
private CompletableFuture<ReceiptItem> convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) {
private ReceiptItem convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId)
throws SubscriptionException.ReceiptRequestedForOpenPayment, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired {
if (latestSubscriptionInvoice == null) {
return CompletableFuture.failedFuture(
ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment()));
throw new SubscriptionException.ReceiptRequestedForOpenPayment();
}
if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) {
return CompletableFuture.failedFuture(
ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment()));
throw new SubscriptionException.ReceiptRequestedForOpenPayment();
}
if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) {
return CompletableFuture.failedFuture(ExceptionUtils.wrap(Optional
.ofNullable(latestSubscriptionInvoice.getChargeObject())
// If the charge object has a failure reason we can present to the user, create a detailed exception
.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)
.<SubscriptionException> map(charge ->
new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge)))
// Otherwise, return a generic payment required error
.orElseGet(() -> new SubscriptionException.PaymentRequired())));
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));
} else {
// Otherwise, return a generic payment required error
throw new SubscriptionException.PaymentRequired();
}
}
return getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> {
Collection<InvoiceLineItem> subscriptionLineItems = invoiceLineItems.stream()
.filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType()))
.toList();
if (subscriptionLineItems.isEmpty()) {
throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId="
+ subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId());
}
if (subscriptionLineItems.size() > 1) {
throw new IllegalStateException(
"latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId
+ "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size());
}
final Collection<InvoiceLineItem> invoiceLineItems = getInvoiceLineItemsForInvoice(latestSubscriptionInvoice);
Collection<InvoiceLineItem> subscriptionLineItems = invoiceLineItems.stream()
.filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType()))
.toList();
if (subscriptionLineItems.isEmpty()) {
throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId="
+ subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId());
}
if (subscriptionLineItems.size() > 1) {
throw new IllegalStateException(
"latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId
+ "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size());
}
InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get();
return getReceiptForSubscription(subscriptionLineItem, latestSubscriptionInvoice);
});
InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get();
return getReceiptForSubscription(subscriptionLineItem, latestSubscriptionInvoice);
}
private CompletableFuture<ReceiptItem> getReceiptForSubscription(InvoiceLineItem subscriptionLineItem,
Invoice invoice) {
private ReceiptItem getReceiptForSubscription(InvoiceLineItem subscriptionLineItem, Invoice invoice) {
final Instant paidAt;
if (invoice.getStatusTransitions().getPaidAt() != null) {
paidAt = Instant.ofEpochSecond(invoice.getStatusTransitions().getPaidAt());
} else {
logger.warn("No paidAt timestamp exists for paid invoice {}, falling back to start of subscription period", invoice.getId());
logger.warn("No paidAt timestamp exists for paid invoice {}, falling back to start of subscription period",
invoice.getId());
paidAt = Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getStart());
}
return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem(
final Product product = getProductForPrice(subscriptionLineItem.getPrice().getId());
return new ReceiptItem(
subscriptionLineItem.getId(),
PaymentTime.periodStart(paidAt),
getLevelForProduct(product)));
getLevelForProduct(product));
}
public CompletableFuture<Collection<InvoiceLineItem>> getInvoiceLineItemsForInvoice(Invoice invoice) {
return CompletableFuture.supplyAsync(
() -> {
try {
final StripeCollection<InvoiceLineItem> lineItems = stripeClient.invoices().lineItems()
.list(invoice.getId(), commonOptions());
return Lists.newArrayList(lineItems.autoPagingIterable());
} catch (final StripeException e) {
throw new CompletionException(e);
}
}, executor);
public Collection<InvoiceLineItem> getInvoiceLineItemsForInvoice(Invoice invoice) {
try {
final StripeCollection<InvoiceLineItem> lineItems = stripeClient.invoices().lineItems()
.list(invoice.getId(), commonOptions());
return Lists.newArrayList(lineItems.autoPagingIterable());
} catch (final StripeException e) {
throw new UncheckedIOException(new IOException(e));
}
}
public CompletableFuture<String> getGeneratedSepaIdFromSetupIntent(String setupIntentId) {

View File

@@ -4,7 +4,9 @@
*/
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;
@@ -27,17 +29,45 @@ 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
*/
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
ReceiptItem getReceiptItem(String subscriptionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.PaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment;
/**
* Cancel all active subscriptions for this key within the payment provider.
* Cancel all active subscriptions for this key within the payment processor.
*
* @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the
* subscriptions table
* @return A stage that completes when all subscriptions associated with the key are cancelled
* @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
*/
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
void cancelAllActiveSubscriptions(String key)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound;
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId);
/**
* 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
*/
SubscriptionInformation getSubscriptionInformation(final String subscriptionId)
throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound;
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import java.util.Collection;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
public class ExecutorUtil {
private ExecutorUtil() {
}
/**
* Submit all runnables to executorService and wait for them all to complete.
* <p>
* If any runnable completes exceptionally, after all runnables have completed the first exception will be thrown
*
* @param executor The executor to run runnables
* @param runnables A collection of runnables to run
*/
public static void runAll(Executor executor, Collection<Runnable> runnables) {
try {
CompletableFuture.allOf(runnables
.stream()
.map(runnable -> CompletableFuture.runAsync(runnable, executor))
.toArray(CompletableFuture[]::new))
.join();
} catch (CompletionException e) {
final Throwable cause = e.getCause();
// These exceptions should always be RuntimeExceptions because Runnable does not throw
if (cause instanceof RuntimeException re) {
throw re;
} else {
throw new IllegalStateException(cause);
}
}
}
}