diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index cd4c0917b..3fc461cf4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -542,11 +542,7 @@ public class WhisperServerService extends Application 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(); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index 907c4ec2d..004f407c3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -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 deleteSubscriber( + @ManagedAsync + public Response deleteSubscriber( @Auth Optional 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 updateSubscriber( + @ManagedAsync + public Response updateSubscriber( @Auth Optional 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 createPaymentMethod( + @ManagedAsync + public CreatePaymentMethodResponse createPaymentMethod( @Auth Optional 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 createPayPalPaymentMethod( + @ManagedAsync + public CreatePayPalBillingAgreementResponse createPayPalPaymentMethod( @Auth Optional 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 setDefaultPaymentMethodWithProcessor( + @ManagedAsync + public Response setDefaultPaymentMethodWithProcessor( @Auth Optional 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 setSubscriptionLevel( + @ManagedAsync + public SetSubscriptionLevelSuccessResponse setSubscriptionLevel( @Auth Optional 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 setAppStoreSubscription( + @ManagedAsync + public SetSubscriptionLevelSuccessResponse setAppStoreSubscription( @Auth Optional 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 setPlayStoreSubscription( + @ManagedAsync + public SetSubscriptionLevelSuccessResponse setPlayStoreSubscription( @Auth Optional 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 getConfiguration(@Context ContainerRequestContext containerRequestContext) { - return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); - return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build(); - }); + @ApiResponse(responseCode = "200", useReturnTypeSchema = true) + @ManagedAsync + public GetSubscriptionConfigurationResponse getConfiguration(@Context ContainerRequestContext containerRequestContext) { + List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); + return buildGetSubscriptionConfigurationResponse(acceptableLanguages); } @GET @Path("/bank_mandate/{bankTransferType}") @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture getBankMandate(final @Context ContainerRequestContext containerRequestContext, + @ManagedAsync + public GetBankMandateResponse getBankMandate(final @Context ContainerRequestContext containerRequestContext, final @PathParam("bankTransferType") BankTransferType bankTransferType) { - return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); - return Response.ok(new GetBankMandateResponse( - bankMandateTranslator.translate(acceptableLanguages, bankTransferType))).build(); - }); + List 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 getSubscriptionInformation( + @ManagedAsync + public GetSubscriptionInformationResponse getSubscriptionInformation( @Auth Optional 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 createSubscriptionReceiptCredentials( + @ManagedAsync + public Response createSubscriptionReceiptCredentials( @Auth Optional 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 setDefaultPaymentMethodForIdeal( + @ManagedAsync + public Response setDefaultPaymentMethodForIdeal( @Auth Optional 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 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) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java index d76eaad08..2142b8e45 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java @@ -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 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 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> 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 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 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 createReceiptCredentials( + public ReceiptResult createReceiptCredentials( final SubscriberCredentials subscriberCredentials, final SubscriptionController.GetReceiptCredentialsRequest request, - final Function expiration) { - return getSubscriber(subscriberCredentials).thenCompose(record -> { - if (record.subscriptionId == null) { - return CompletableFuture.failedFuture(new SubscriptionException.NotFound()); - } + final Function 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 A payment processor that has a notion of server-managed customers * @param 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 CompletableFuture addPaymentMethodToCustomer( + public R addPaymentMethodToCustomer( final SubscriberCredentials subscriberCredentials, final T subscriptionPaymentProcessor, final ClientPlatform clientPlatform, - final BiFunction> paymentSetupFunction) { - return this.getSubscriber(subscriberCredentials).thenCompose(record -> record.getProcessorCustomer() - .map(ProcessorCustomer::processor) - .map(processor -> { - if (processor != subscriptionPaymentProcessor.getProvider()) { - return CompletableFuture.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 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 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 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 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) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java index d06325e9e..447548e0c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java @@ -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 productIdToLevel; private static final Status[] EMPTY_STATUSES = new Status[0]; @@ -86,12 +78,10 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { final String subscriptionGroupId, final Map productIdToLevel, final List 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 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> retryConfigBuilder = - RetryConfig.from(Optional.ofNullable(retryConfigurationName) + this.retry = ResilienceUtil.getRetryRegistry().retry("appstore-retry", RetryConfig + .>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 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 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 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 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 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 txs = item.getLastTransactions().stream() + .map(this::decode) + .filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId())) + .toList(); - final List 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 getAllSubscriptions(final String originalTransactionId) { - Supplier> 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; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index 059b6c7d3..a114ef894 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -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 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 result = braintreeGateway.customer().create(request); + if (!result.isSuccess()) { + throw new BraintreeException(result.getMessage()); + } + return new ProcessorCustomer(result.getTarget().getId(), PaymentProvider.BRAINTREE); } @Override - public CompletableFuture 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 setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken, + public void setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken, @Nullable String currentSubscriptionId) { final Optional 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 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 getSubscription(String subscriptionId) { - return CompletableFuture.supplyAsync(() -> braintreeGateway.subscription().find(subscriptionId), executor); + public Object getSubscription(String subscriptionId) { + return braintreeGateway.subscription().find(subscriptionId); } @Override - public CompletableFuture 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 maybeExistingSubscription = paymentMethod.getSubscriptions().stream() - .filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE)) - .filter(Subscription::neverExpires) - .findAny(); + final Optional 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 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 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 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 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 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 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 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 cancelAllActiveSubscriptions(String customerId) { - - return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId), executor).thenCompose(customer -> { - - final List> 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()) + .map(subscription -> () -> this.endSubscription(subscription)) + .toList()); } - private CompletableFuture 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 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 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 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) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java index 2e853f07a..7173e1ef7 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java @@ -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 getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod); - CompletableFuture 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 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 setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, - @Nullable String currentSubscriptionId); + void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, + @Nullable String currentSubscriptionId) throws SubscriptionException.InvalidArguments; - CompletableFuture getSubscription(String subscriptionId); + Object getSubscription(String subscriptionId); - CompletableFuture 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 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 getLevelAndCurrencyForSubscription(Object subscription); + LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscription); record SubscriptionId(String id) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java index 7ce51b083..2d470c4b8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java @@ -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 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 productIdToLevel, - final Executor executor) + final Map 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 productIdToLevel, - final Executor executor) { + final Map 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 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 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 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 getSubscriptionInformation(final String purchaseToken) { + public SubscriptionInformation getSubscriptionInformation(final String purchaseToken) + throws RateLimitExceededException, SubscriptionException.NotFound { - final CompletableFuture subscriptionFuture = lookupSubscription(purchaseToken); - final CompletableFuture 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 billingCycleAnchor = getStartTime(subscription); + final Optional expiration = getExpiration(lineItem); - final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription); - final Optional billingCycleAnchor = getStartTime(subscription); - final Optional 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 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 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 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 The return type of the executed ApiCall - * @return A stage that completes with the result of the API call - */ - private CompletableFuture executeAsync(final ApiCall 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 The result of the API call * @return A stage that completes with the result of the API call */ - private CompletableFuture executeTokenOperation(final ApiCall 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 executeTokenOperation(final ApiCall 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 lookupSubscription(final String purchaseToken) { + private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken) + throws RateLimitExceededException, SubscriptionException.NotFound { return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken)); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index d98d1e450..2d9fba314 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -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> supportedCurrenciesByPaymentMethod; - public StripeManager( - @Nonnull String apiKey, + @VisibleForTesting + StripeManager( + @Nonnull StripeClient stripeClient, @Nonnull Executor executor, @Nonnull byte[] idempotencyKeyGenerator, @Nonnull String boostDescription, @Nonnull Map> 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> 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 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 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 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 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 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 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 items = new ArrayList<>(); - try { - final StripeCollection 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 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 items = new ArrayList<>(); + try { + final StripeCollection 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 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[] futures = (CompletableFuture[]) subscriptions.stream() - .map(this::endSubscription).toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); - }); + + final Collection 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() + .map(subscription -> () -> this.endSubscription(subscription)) + .toList()); } - public CompletableFuture> 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 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 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 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 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> getItemsForSubscription(Subscription subscription) { - return CompletableFuture.supplyAsync( - () -> { - try { - final StripeCollection subscriptionItems = stripeClient.subscriptionItems().list( - SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(), commonOptions()); - return Lists.newArrayList(subscriptionItems.autoPagingIterable()); + public Collection getItemsForSubscription(Subscription subscription) { + try { + final StripeCollection 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 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 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 getProductForSubscription(Subscription subscription) { - return getPriceForSubscription(subscription).thenCompose(price -> getProductForPrice(price.getId())); + private Product getProductForSubscription(Subscription subscription) { + return getProductForPrice(getPriceForSubscription(subscription).getId()); } @Override - public CompletableFuture 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 getLevelForPrice(Price price) { - return getProductForPrice(price.getId()).thenApply(this::getLevelForProduct); + public long getLevelForPrice(Price price) { + return getLevelForProduct(getProductForPrice(price.getId())); } - public CompletableFuture 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> 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 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 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 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 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 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 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) - . 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 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 invoiceLineItems = getInvoiceLineItemsForInvoice(latestSubscriptionInvoice); + Collection 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 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> getInvoiceLineItemsForInvoice(Invoice invoice) { - return CompletableFuture.supplyAsync( - () -> { - try { - final StripeCollection lineItems = stripeClient.invoices().lineItems() - .list(invoice.getId(), commonOptions()); - return Lists.newArrayList(lineItems.autoPagingIterable()); - } catch (final StripeException e) { - throw new CompletionException(e); - } - }, executor); + public Collection getInvoiceLineItemsForInvoice(Invoice invoice) { + try { + final StripeCollection 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 getGeneratedSepaIdFromSetupIntent(String setupIntentId) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java index 93440d844..12c44e6d5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java @@ -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 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 cancelAllActiveSubscriptions(String key); + void cancelAllActiveSubscriptions(String key) + throws SubscriptionException.InvalidArguments, RateLimitExceededException, SubscriptionException.NotFound; - CompletableFuture 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; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtil.java new file mode 100644 index 000000000..3e86519ce --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtil.java @@ -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. + *

+ * 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 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); + } + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java index 434a6be4e..d6526dfa4 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java @@ -116,8 +116,8 @@ class DonationControllerTest { when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration); when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn( CompletableFuture.completedFuture(Boolean.TRUE)); - when(accountsManager.getByAccountIdentifierAsync(eq(AuthHelper.VALID_UUID))).thenReturn( - CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT))); + when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID))) + .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true); Response response = resources.getJerseyTest() @@ -140,8 +140,8 @@ class DonationControllerTest { when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration); when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn( CompletableFuture.completedFuture(Boolean.FALSE)); - when(accountsManager.getByAccountIdentifierAsync(eq(AuthHelper.VALID_UUID))).thenReturn( - CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT))); + when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID))) + .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true); Response response = resources.getJerseyTest() diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index d820b7d89..9552a0061 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -28,6 +28,8 @@ import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.io.UncheckedIOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; @@ -42,7 +44,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.function.Predicate; import java.util.stream.Stream; import org.assertj.core.api.InstanceOfAssertFactories; @@ -399,9 +400,9 @@ class SubscriptionControllerTest { } @Test - void createSubscriptionSuccess() { + void createSubscriptionSuccess() throws SubscriptionException { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class))); + .thenReturn(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class)); final String level = String.valueOf(levelId); final String idempotencyKey = UUID.randomUUID().toString(); @@ -414,10 +415,10 @@ class SubscriptionControllerTest { } @Test - void createSubscriptionProcessorDeclined() { + void createSubscriptionProcessorDeclined() throws SubscriptionException { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE, - new ChargeFailure("card_declined", "Insufficient funds", null, null, null)))); + .thenThrow(new SubscriptionException.ProcessorException(PaymentProvider.STRIPE, + new ChargeFailure("card_declined", "Insufficient funds", null, null, null))); final String level = String.valueOf(levelId); final String idempotencyKey = UUID.randomUUID().toString(); @@ -489,11 +490,10 @@ class SubscriptionControllerTest { } @Test - void stripePaymentIntentRequiresAction() { - final ApiException stripeException = new ApiException("Payment intent requires action", - UUID.randomUUID().toString(), "subscription_payment_intent_requires_action", 400, new Exception()); + void stripePaymentIntentRequiresAction() + throws SubscriptionException.InvalidArguments, SubscriptionException.ProcessorException { when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.failedFuture(new CompletionException(stripeException))); + .thenThrow(new SubscriptionException.PaymentRequiresAction()); final String level = String.valueOf(levelId); final String idempotencyKey = UUID.randomUUID().toString(); @@ -624,7 +624,7 @@ class SubscriptionControllerTest { final ProcessorCustomer customer = new ProcessorCustomer( customerId, PaymentProvider.STRIPE); when(STRIPE_MANAGER.createCustomer(any(), any())) - .thenReturn(CompletableFuture.completedFuture(customer)); + .thenReturn(customer); final Map dynamoItemWithProcessorCustomer = new HashMap<>(dynamoItem); dynamoItemWithProcessorCustomer.put(Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID, @@ -638,7 +638,7 @@ class SubscriptionControllerTest { final String clientSecret = "some-client-secret"; when(STRIPE_MANAGER.createPaymentMethodSetupToken(customerId)) - .thenReturn(CompletableFuture.completedFuture(clientSecret)); + .thenReturn(clientSecret); final SubscriptionController.CreatePaymentMethodResponse createPaymentMethodResponse = RESOURCE_EXTENSION .target(String.format("/v1/subscription/%s/create_payment_method", subscriberId)) @@ -687,7 +687,8 @@ class SubscriptionControllerTest { "35, M3", "201, M4", }) - void setSubscriptionLevel(long levelId, String expectedProcessorId) { + void setSubscriptionLevel(long levelId, String expectedProcessorId) + throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { // set up record final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -711,8 +712,7 @@ class SubscriptionControllerTest { .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId( - "subscription"))); + .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId("subscription")); when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -734,7 +734,8 @@ class SubscriptionControllerTest { @ParameterizedTest @MethodSource void setSubscriptionLevelExistingSubscription(final String existingCurrency, final long existingLevel, - final String requestCurrency, final long requestLevel, final boolean expectUpdate) { + final String requestCurrency, final long requestLevel, final boolean expectUpdate) + throws SubscriptionException.ProcessorConflict, SubscriptionException.ProcessorException { // set up record final byte[] subscriberUserAndKey = new byte[32]; @@ -761,17 +762,14 @@ class SubscriptionControllerTest { .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); final Object subscriptionObj = new Object(); - when(BRAINTREE_MANAGER.getSubscription(any())) - .thenReturn(CompletableFuture.completedFuture(subscriptionObj)); + when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj); when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) - .thenReturn(CompletableFuture.completedFuture( - new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency))); + .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)); final String updatedSubscriptionId = "updatedSubscriptionId"; if (expectUpdate) { when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString())) - .thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId( - updatedSubscriptionId))); + .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(updatedSubscriptionId)); when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString())) .thenReturn(CompletableFuture.completedFuture(null)); } @@ -836,11 +834,9 @@ class SubscriptionControllerTest { .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); final Object subscriptionObj = new Object(); - when(BRAINTREE_MANAGER.getSubscription(any())) - .thenReturn(CompletableFuture.completedFuture(subscriptionObj)); + when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj); when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) - .thenReturn(CompletableFuture.completedFuture( - new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd"))); + .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")); // Try to change from a backup subscription (201) to a donation subscription (5) final Response response = RESOURCE_EXTENSION @@ -857,7 +853,8 @@ class SubscriptionControllerTest { } @Test - public void setAppStoreTransactionId() { + public void setAppStoreTransactionId() + throws SubscriptionException.InvalidArguments, SubscriptionException.PaymentRequired, RateLimitExceededException, SubscriptionException.NotFound { final String originalTxId = "aTxId"; final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -877,7 +874,7 @@ class SubscriptionControllerTest { .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); when(APPSTORE_MANAGER.validateTransaction(eq(originalTxId))) - .thenReturn(CompletableFuture.completedFuture(99L)); + .thenReturn(99L); when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -900,7 +897,7 @@ class SubscriptionControllerTest { @Test - public void setPlayPurchaseToken() { + public void setPlayPurchaseToken() throws RateLimitExceededException, SubscriptionException { final String purchaseToken = "aPurchaseToken"; final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -920,8 +917,7 @@ class SubscriptionControllerTest { final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class); when(validatedToken.getLevel()).thenReturn(99L); - when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null)); - when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken)); + when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(validatedToken); when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -943,7 +939,7 @@ class SubscriptionControllerTest { } @Test - public void replacePlayPurchaseToken() { + public void replacePlayPurchaseToken() throws RateLimitExceededException, SubscriptionException { final String oldPurchaseToken = "oldPurchaseToken"; final String newPurchaseToken = "newPurchaseToken"; final byte[] subscriberUserAndKey = new byte[32]; @@ -965,11 +961,8 @@ class SubscriptionControllerTest { final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class); when(validatedToken.getLevel()).thenReturn(99L); - when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null)); - when(PLAY_MANAGER.validateToken(eq(newPurchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken)); - when(PLAY_MANAGER.cancelAllActiveSubscriptions(eq(oldPurchaseToken))) - .thenReturn(CompletableFuture.completedFuture(null)); + when(PLAY_MANAGER.validateToken(eq(newPurchaseToken))).thenReturn(validatedToken); when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -993,7 +986,8 @@ class SubscriptionControllerTest { } @Test - void createReceiptChargeFailure() throws InvalidInputException, VerificationFailedException { + void createReceiptChargeFailure() + throws InvalidInputException, VerificationFailedException, SubscriptionException { final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); @@ -1009,9 +1003,9 @@ class SubscriptionControllerTest { b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()), Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId")))))); when(STRIPE_MANAGER.getReceiptItem(any())) - .thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ChargeFailurePaymentRequired( + .thenThrow(new SubscriptionException.ChargeFailurePaymentRequired( PaymentProvider.STRIPE, - new ChargeFailure("card_declined", "Insufficient funds", null, null, null)))); + new ChargeFailure("card_declined", "Insufficient funds", null, null, null))); final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations( ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext( @@ -1033,7 +1027,7 @@ class SubscriptionControllerTest { @ParameterizedTest @CsvSource({"5, P45D", "201, P13D"}) public void createReceiptCredential(long level, Duration expectedExpirationWindow) - throws InvalidInputException, VerificationFailedException { + throws InvalidInputException, VerificationFailedException, SubscriptionException.ChargeFailurePaymentRequired, SubscriptionException.ReceiptRequestedForOpenPayment { final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); @@ -1057,11 +1051,10 @@ class SubscriptionControllerTest { when(SUBSCRIPTIONS.get(any(), any())) .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record))); when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn( - CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem( + new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem( "itemId", PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))), - level - ))); + level)); when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any())) .thenReturn(CompletableFuture.completedFuture(null)); when(ZK_OPS.issueReceiptCredential(any(), anyLong(), eq(level))).thenReturn(receiptCredentialResponse); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java index 8ca4b2d54..a78dd3e71 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java @@ -6,6 +6,8 @@ package org.whispersystems.textsecuregcm.subscriptions; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; @@ -26,6 +28,7 @@ import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem; import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.apple.itunes.storekit.verification.VerificationException; import java.io.IOException; +import java.io.UncheckedIOException; import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; @@ -39,8 +42,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.storage.SubscriptionException; -import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; class AppleAppStoreManagerTest { @@ -54,27 +57,19 @@ class AppleAppStoreManagerTest { private final AppStoreServerAPIClient apiClient = mock(AppStoreServerAPIClient.class); private final SignedDataVerifier signedDataVerifier = mock(SignedDataVerifier.class); - private ScheduledExecutorService executor; private AppleAppStoreManager appleAppStoreManager; @BeforeEach public void setup() { reset(apiClient, signedDataVerifier); - executor = Executors.newSingleThreadScheduledExecutor(); appleAppStoreManager = new AppleAppStoreManager(apiClient, signedDataVerifier, - SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), null, executor, executor); - } - - @AfterEach - public void teardown() throws InterruptedException { - executor.shutdownNow(); - executor.awaitTermination(1, TimeUnit.SECONDS); + SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), null); } @Test - public void lookupTransaction() throws APIException, IOException, VerificationException { + public void lookupTransaction() throws APIException, IOException, VerificationException, SubscriptionException, RateLimitExceededException { mockValidSubscription(); - final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID); assertThat(info.active()).isTrue(); assertThat(info.paymentProcessing()).isFalse(); @@ -85,15 +80,17 @@ class AppleAppStoreManagerTest { } @Test - public void validateTransaction() throws VerificationException, APIException, IOException { + public void validateTransaction() + throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException { mockValidSubscription(); - assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID).join()).isEqualTo(LEVEL); + assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID)).isEqualTo(LEVEL); } @Test - public void generateReceipt() throws VerificationException, APIException, IOException { + public void generateReceipt() + throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException { mockValidSubscription(); - final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID).join(); + final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID); assertThat(receipt.level()).isEqualTo(LEVEL); assertThat(receipt.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO)) .isEqualTo(Instant.EPOCH.plus(Duration.ofDays(2))); @@ -101,16 +98,17 @@ class AppleAppStoreManagerTest { } @Test - public void generateReceiptExpired() throws VerificationException, APIException, IOException { + public void generateReceiptExpired() + throws VerificationException, APIException, IOException { mockSubscription(Status.EXPIRED, AutoRenewStatus.ON); - CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class, - appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID)); + assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + .isThrownBy(() -> appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID)); } @Test - public void autoRenewOff() throws VerificationException, APIException, IOException { + public void autoRenewOff() throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException { mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF); - final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID); assertThat(info.cancelAtPeriodEnd()).isTrue(); @@ -121,7 +119,7 @@ class AppleAppStoreManagerTest { } @Test - public void lookupMultipleProducts() throws APIException, IOException, VerificationException { + public void lookupMultipleProducts() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException { // The lookup should select the transaction at i=1 final List products = List.of("otherProduct1", PRODUCT_ID, "otherProduct3"); @@ -149,14 +147,14 @@ class AppleAppStoreManagerTest { .originalPurchaseDate(Instant.EPOCH.toEpochMilli()) .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli())); } - final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID); assertThat(info.price().amount().compareTo(new BigDecimal("100"))).isEqualTo(0); } @Test - public void retryEventuallyWorks() throws APIException, IOException, VerificationException { + public void retryEventuallyWorks() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException { // Should retry up to 3 times when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) @@ -169,7 +167,7 @@ class AppleAppStoreManagerTest { .signedRenewalInfo(SIGNED_RENEWAL_INFO) .signedTransactionInfo(SIGNED_TX_INFO))))); mockDecode(AutoRenewStatus.ON); - final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID); assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE); } @@ -179,8 +177,10 @@ class AppleAppStoreManagerTest { when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")); mockDecode(AutoRenewStatus.ON); - CompletableFutureTestUtil.assertFailsWithCause(APIException.class, - appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID)); + assertThatException() + .isThrownBy(() -> appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID)) + .isInstanceOf(UncheckedIOException.class) + .withRootCauseInstanceOf(APIException.class); verify(apiClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); @@ -189,22 +189,22 @@ class AppleAppStoreManagerTest { @Test public void cancelRenewalDisabled() throws APIException, VerificationException, IOException { mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF); - assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join()); + assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); } @ParameterizedTest @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"EXPIRED", "REVOKED"}) public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException { mockSubscription(status, AutoRenewStatus.ON); - CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.InvalidArguments.class, - appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); + assertThatExceptionOfType(SubscriptionException.InvalidArguments.class) + .isThrownBy(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); } @ParameterizedTest @EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"EXPIRED", "REVOKED"}) public void cancelInactiveStatus(Status status) throws APIException, VerificationException, IOException { mockSubscription(status, AutoRenewStatus.ON); - assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join()); + assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); } private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java index f8841f3ad..8a4dd7091 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java @@ -51,6 +51,6 @@ class BraintreeManagerTest { when(braintreeGateway.customer()).thenReturn(customerGateway); assertTimeoutPreemptively(Duration.ofSeconds(5), () -> - braintreeManager.cancelAllActiveSubscriptions("customerId")).join(); + braintreeManager.cancelAllActiveSubscriptions("customerId")); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java index dd36b2155..3f065121f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java @@ -5,6 +5,8 @@ package org.whispersystems.textsecuregcm.subscriptions; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -31,12 +33,7 @@ import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -45,7 +42,6 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.storage.SubscriptionException; -import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MutableClock; @@ -74,7 +70,6 @@ class GooglePlayBillingManagerTest { private final MutableClock clock = MockUtils.mutableClock(0L); - private ExecutorService executor; private GooglePlayBillingManager googlePlayBillingManager; @BeforeEach @@ -105,19 +100,12 @@ class GooglePlayBillingManagerTest { when(monetization.subscriptions()).thenReturn(msubscriptions); when(msubscriptions.get(PACKAGE_NAME, PRODUCT_ID)).thenReturn(subscriptionConfig); - executor = Executors.newSingleThreadExecutor(); googlePlayBillingManager = new GooglePlayBillingManager( - androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor); - } - - @AfterEach - public void teardown() throws InterruptedException { - executor.shutdownNow(); - executor.awaitTermination(1, TimeUnit.SECONDS); + androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L)); } @Test - public void validatePurchase() throws IOException { + public void validatePurchase() throws IOException, RateLimitExceededException, SubscriptionException { when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2() .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString()) .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString()) @@ -125,11 +113,10 @@ class GooglePlayBillingManagerTest { .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString()) .setProductId(PRODUCT_ID)))); - final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager - .validateToken(PURCHASE_TOKEN).join(); + final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN); assertThat(result.getLevel()).isEqualTo(201); - assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join()); + assertThatNoException().isThrownBy(result::acknowledgePurchase); verify(acknowledge, times(1)).execute(); } @@ -143,16 +130,16 @@ class GooglePlayBillingManagerTest { .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString()) .setProductId(PRODUCT_ID)))); - final CompletableFuture future = googlePlayBillingManager - .validateToken(PURCHASE_TOKEN); switch (subscriptionState) { - case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException().isThrownBy(() -> future.join()); - default -> CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class, future); + case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException() + .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN)); + default -> assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN)); } } @Test - public void avoidDoubleAcknowledge() throws IOException { + public void avoidDoubleAcknowledge() throws IOException, RateLimitExceededException, SubscriptionException { when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2() .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString()) .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString()) @@ -160,11 +147,10 @@ class GooglePlayBillingManagerTest { .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString()) .setProductId(PRODUCT_ID)))); - final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager - .validateToken(PURCHASE_TOKEN).join(); + final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN); assertThat(result.getLevel()).isEqualTo(201); - assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join()); + assertThatNoException().isThrownBy(result::acknowledgePurchase); verifyNoInteractions(acknowledge); } @@ -178,7 +164,7 @@ class GooglePlayBillingManagerTest { .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString()) .setProductId(PRODUCT_ID)))); assertThatNoException().isThrownBy(() -> - googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join()); + googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN)); final int wanted = switch (subscriptionState) { case CANCELED, EXPIRED -> 0; default -> 1; @@ -192,7 +178,7 @@ class GooglePlayBillingManagerTest { when(mockException.getStatusCode()).thenReturn(404); when(subscriptionsv2Get.execute()).thenThrow(mockException); assertThatNoException().isThrownBy(() -> - googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join()); + googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN)); verifyNoInteractions(cancel); } @@ -201,8 +187,8 @@ class GooglePlayBillingManagerTest { final HttpResponseException mockException = mock(HttpResponseException.class); when(mockException.getStatusCode()).thenReturn(429); when(subscriptionsv2Get.execute()).thenThrow(mockException); - CompletableFutureTestUtil.assertFailsWithCause( - RateLimitExceededException.class, googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN)); + assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(() -> + googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN)); } @Test @@ -213,13 +199,13 @@ class GooglePlayBillingManagerTest { .setLineItems(List.of(new SubscriptionPurchaseLineItem() .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString()) .setProductId(PRODUCT_ID)))); - CompletableFutureTestUtil.assertFailsWithCause( - IllegalStateException.class, + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN)); } @Test - public void getReceiptExpiring() throws IOException { + public void getReceiptExpiring() + throws IOException, RateLimitExceededException, SubscriptionException { final Instant day9 = Instant.EPOCH.plus(Duration.ofDays(9)); final Instant day10 = Instant.EPOCH.plus(Duration.ofDays(10)); @@ -232,7 +218,7 @@ class GooglePlayBillingManagerTest { .setProductId(PRODUCT_ID)))); clock.setTimeInstant(day9); - SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join(); + SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN); assertThat(item.itemId()).isEqualTo(ORDER_ID); assertThat(item.level()).isEqualTo(201L); @@ -242,19 +228,18 @@ class GooglePlayBillingManagerTest { // should still be able to get a receipt the next day clock.setTimeInstant(day10); - item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join(); + item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN); assertThat(item.itemId()).isEqualTo(ORDER_ID); // next second should be expired clock.setTimeInstant(day10.plus(Duration.ofSeconds(1))); - CompletableFutureTestUtil.assertFailsWithCause( - SubscriptionException.PaymentRequired.class, - googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN)); + assertThatExceptionOfType(SubscriptionException.PaymentRequired.class) + .isThrownBy(() -> googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN)); } @Test - public void getSubscriptionInfo() throws IOException { + public void getSubscriptionInfo() throws IOException, RateLimitExceededException, SubscriptionException { final String basePlanId = "basePlanId"; when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2() .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString()) @@ -275,7 +260,7 @@ class GooglePlayBillingManagerTest { .setPrice(new Money().setCurrencyCode("USD").setUnits(1L).setNanos(750_000_000)))); when(subscriptionConfig.execute()).thenReturn(new Subscription().setBasePlans(List.of(basePlan))); - final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN).join(); + final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN); assertThat(info.active()).isTrue(); assertThat(info.paymentProcessing()).isFalse(); assertThat(info.price().currency()).isEqualTo("USD"); @@ -298,9 +283,17 @@ class GooglePlayBillingManagerTest { final HttpResponseException mockException = mock(HttpResponseException.class); when(mockException.getStatusCode()).thenReturn(httpStatus); when(subscriptionsv2Get.execute()).thenThrow(mockException); - - CompletableFutureTestUtil.assertFailsWithCause(expected, - googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN)); + assertThatException() + .isThrownBy(() -> googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN)) + // Verify the exception or its leaf cause is an instanceof expected. withRootCauseInstanceOf almost does what we + // want, but fails if the outermost exception does not have a cause + .matches(e -> { + Throwable cause = e; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return expected.isInstance(cause); + }); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java new file mode 100644 index 000000000..fd465e7c4 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.braintreegateway.BraintreeGateway; +import com.braintreegateway.Customer; +import com.braintreegateway.CustomerGateway; +import com.google.cloud.pubsub.v1.Publisher; +import com.stripe.StripeClient; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import com.stripe.exception.ApiException; +import com.stripe.exception.StripeException; +import com.stripe.service.SubscriptionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; +import org.whispersystems.textsecuregcm.storage.SubscriptionException; + +class StripeManagerTest { + + private StripeClient stripeClient; + private StripeManager stripeManager; + private ExecutorService executor; + + @BeforeEach + void setup() { + this.executor = Executors.newSingleThreadExecutor(); + this.stripeClient = mock(StripeClient.class); + this.stripeManager = new StripeManager( + this.stripeClient, + executor, + "idempotencyKey".getBytes(StandardCharsets.UTF_8), + "boost", + Map.of(PaymentMethod.CARD, Set.of("usd"))); + } + + @AfterEach + void teardown() throws InterruptedException { + this.executor.shutdownNow(); + this.executor.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + void paymentRequiresAction() throws StripeException { + final SubscriptionService subscriptionService = mock(SubscriptionService.class); + final ApiException stripeException = new ApiException("Payment intent requires action", + UUID.randomUUID().toString(), "subscription_payment_intent_requires_action", 400, new Exception()); + + when(subscriptionService.create(any(), any())).thenThrow(stripeException); + when(stripeClient.subscriptions()).thenReturn(subscriptionService); + assertThatExceptionOfType(SubscriptionException.PaymentRequiresAction.class).isThrownBy(() -> + stripeManager.createSubscription("customerId", "priceId", 1, 0)); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/ExecutorUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/ExecutorUtilTest.java new file mode 100644 index 000000000..576954d89 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/ExecutorUtilTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + + +class ExecutorUtilTest { + + private ExecutorService executor; + + @BeforeEach + void setUp() { + this.executor = Executors.newSingleThreadExecutor(); + } + + @AfterEach + void tearDown() throws InterruptedException { + this.executor.shutdown(); + this.executor.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + void runAllWaits() { + final AtomicLong c = new AtomicLong(5); + ExecutorUtil.runAll(executor, Stream + .generate(() -> () -> { + Util.sleep(1); + c.decrementAndGet(); + }) + .limit(5) + .toList()); + assertThat(c.get()).isEqualTo(0); + } + + @Test + void runAllWithException() { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> ExecutorUtil.runAll(executor, List.of(Util.NOOP, Util.NOOP, () -> { + throw new IllegalStateException("oof"); + }))); + } + + @Test + void runAllEmpty() { + assertThatNoException().isThrownBy(() -> ExecutorUtil.runAll(executor, List.of())); + } + +}