mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 00:38:04 +01:00
Make SubscriptionController synchronous
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 don’t 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, it’s 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, it’s 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) {
|
||||
|
||||
@@ -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 subscription’s current level and lower-case currency code
|
||||
*/
|
||||
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
|
||||
LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscription);
|
||||
|
||||
record SubscriptionId(String id) {
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user