From 80c11e7eda101523090ad3bc7b0882c8c8de9f63 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Mon, 7 Jul 2025 13:36:24 -0500 Subject: [PATCH] Handle 429s from play API and add subscription docs --- .../controllers/SubscriptionController.java | 87 ++++++++++++++++++- .../mappers/SubscriptionExceptionMapper.java | 10 +-- .../storage/SubscriptionException.java | 9 +- .../subscriptions/BraintreeManager.java | 2 +- .../GooglePlayBillingManager.java | 7 +- .../subscriptions/StripeManager.java | 3 +- .../SubscriptionControllerTest.java | 38 ++++++++ .../GooglePlayBillingManagerTest.java | 10 +++ 8 files changed, 154 insertions(+), 12 deletions(-) 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 0b00aea24..907c4ec2d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -15,6 +15,7 @@ import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -66,6 +67,7 @@ import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.PurchasableBadge; +import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; @@ -218,6 +220,19 @@ public class SubscriptionController { @DELETE @Path("/{subscriberId}") @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Cancel a subscription", description = """ + Cancels any current subscription at the end of the current subscription period. + + Note: Apple IAP subscriptions do not support server-side cancellation, so this method should only be called after + cancelling a subscription from storekit to keep server data up to date. + """) + @ApiResponse(responseCode = "200", description = "All subscriptions cancelled") + @ApiResponse(responseCode = "403", description = "Account authentication is present") + @ApiResponse(responseCode = "404", description = "subscriberId is not found or malformed") + @ApiResponse(responseCode = "400", description = "The associated subscription is not a type that can be cancelled") + @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( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId) throws SubscriptionException { @@ -230,6 +245,16 @@ public class SubscriptionController { @Path("/{subscriberId}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create/refresh a subscriber", description = """ + Creates a subscriber record if it does not exist, otherwise refreshes its last access time. + + Subscribers MUST periodically hit this endpoint to update the access time on the subscription record. Subscribers + SHOULD attempt to make an update call approximately every 3 days. Not accessing this endpoint for an extended + period of time will result in the subscription being canceled. + """) + @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( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId) throws SubscriptionException { @@ -429,7 +454,9 @@ public class SubscriptionController { @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") @ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.") - @ApiResponse(responseCode = "429", description = "Rate limit exceeded.") + @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( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId, @@ -471,6 +498,9 @@ public class SubscriptionController { @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") @ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.") + @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( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId, @@ -625,6 +655,9 @@ public class SubscriptionController { @ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class))) @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( @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId) throws SubscriptionException { @@ -650,16 +683,64 @@ public class SubscriptionController { .orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build())); } - public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) { + public record GetReceiptCredentialsRequest( + @Schema(description = "A ReceiptCredentialRequest encoded in standard base64 with padding") + @NotEmpty byte[] receiptCredentialRequest) { } - public record GetReceiptCredentialsResponse(@NotEmpty byte[] receiptCredentialResponse) { + public record GetReceiptCredentialsResponse( + @Schema(description = "A ReceiptCredentialResponse encoded in standard base64 with padding") + @NotEmpty byte[] receiptCredentialResponse) { } @POST @Path("/{subscriberId}/receipt_credentials") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create receipt credentials", description = """ + Create a receipt from a valid payment invoice that can be used to obtain an entitlement + + This request is repeatable so long as the ReceiptCredentialRequest remains the same. Clients should use the same + ReceiptCredentialRequest value until they attempt to redeem the resulting ReceiptCredentialPresentation. After + this point, the ReceiptCredentialRequest MUST NOT be reused or you may not be able to redeem a valid payment + invoice. Clients SHOULD retry requests at this endpoint with the same ReceiptCredentialRequest value until + receiving a response. After receiving a response, clients should then compute the ReceiptCredentialPresentation + and redeem it at the receipt redemption endpoint. Once the first attempt is made there, the same + ReceiptCredentialRequest MUST NOT be used again to request receipt credentials. + + Note that you may in fact redeem TWO or more invoices for the same ReceiptCredentialRequest while retrying this + operation if a later invoice gets paid while you are retrying. However, the returned receipt is always for the + latest invoice, so it will have the latest expiration possible and no entitlement time will be lost. The important + thing is not to reuse ReceiptCredentialRequest after you have started attempting to redeem the associated + ReceiptCredentialPresentation. Then you may produce a ReceiptCredentialPresentation for a later invoice that + cannot be redeemed. + + 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 = "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.", + content = @Content(schema = @Schema( + nullable = true, + example = """ + { + "chargeFailure": { + "code": "incorrect_account_holder_name", + "message": "The transaction can't be processed because your customer's account information is missing [...]", + "outcomeNetworkStatus": "declined_by_network", + "outcomeReason": "generic_decline", + "outcomeType": "issuer_declined" + } + } + """, + implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class))) + @ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present") + @ApiResponse(responseCode = "404", description = "subscriberId is not found OR malformed OR no subscription setup on the subscriber id") + @ApiResponse(responseCode = "409", description = "latest paid receipt on subscription was already redeemed for a receipt credential but with a different receipt credential request") + @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( @Auth Optional authenticatedAccount, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java index dcd08d171..0b1fba0cd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java @@ -13,11 +13,14 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import java.util.Map; import org.whispersystems.textsecuregcm.storage.SubscriptionException; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; public class SubscriptionExceptionMapper implements ExceptionMapper { @VisibleForTesting public static final int PROCESSOR_ERROR_STATUS_CODE = 440; + public record ChargeFailureResponse(String processor, ChargeFailure chargeFailure) {} + @Override public Response toResponse(final SubscriptionException exception) { @@ -31,17 +34,14 @@ public class SubscriptionExceptionMapper implements ExceptionMapper charge.getFailureCode() != null || charge.getFailureMessage() != null) - . map(charge -> new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(charge))) + . map(charge -> + new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge))) // Otherwise, return a generic payment required error .orElseGet(() -> new SubscriptionException.PaymentRequired()))); 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 9eacb652f..d820b7d89 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -992,6 +992,44 @@ class SubscriptionControllerTest { verify(PLAY_MANAGER, times(1)).cancelAllActiveSubscriptions(oldPurchaseToken); } + @Test + void createReceiptChargeFailure() throws InvalidInputException, VerificationFailedException { + final byte[] subscriberUserAndKey = new byte[32]; + Arrays.fill(subscriberUserAndKey, (byte) 1); + final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); + + when(CLOCK.instant()).thenReturn(Instant.now()); + when(SUBSCRIPTIONS.get(any(), any())) + .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(Subscriptions.Record.from( + Arrays.copyOfRange(subscriberUserAndKey, 0, 16), + Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]), + Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), + Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()), + Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID, + b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()), + Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId")))))); + when(STRIPE_MANAGER.getReceiptItem(any())) + .thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ChargeFailurePaymentRequired( + PaymentProvider.STRIPE, + new ChargeFailure("card_declined", "Insufficient funds", null, null, null)))); + + final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations( + ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext( + new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest(); + final Response response = RESOURCE_EXTENSION + .target(String.format("/v1/subscription/%s/receipt_credentials", subscriberId)) + .request() + .post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize()))); + + assertThat(response.getStatus()).isEqualTo(402); + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("processor")).isEqualTo("STRIPE"); + assertThat(responseMap.get("chargeFailure")).asInstanceOf( + InstanceOfAssertFactories.map(String.class, Object.class)) + .extracting("code") + .isEqualTo("card_declined"); + } + @ParameterizedTest @CsvSource({"5, P45D", "201, P13D"}) public void createReceiptCredential(long level, Duration expectedExpirationWindow) 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 f8139b587..6179f034f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java @@ -42,6 +42,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.storage.SubscriptionException; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.MockUtils; @@ -191,6 +192,15 @@ class GooglePlayBillingManagerTest { verifyNoInteractions(cancel); } + @Test + public void handle429() throws IOException { + final HttpResponseException mockException = mock(HttpResponseException.class); + when(mockException.getStatusCode()).thenReturn(429); + when(subscriptionsv2Get.execute()).thenThrow(mockException); + CompletableFutureTestUtil.assertFailsWithCause( + RateLimitExceededException.class, googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN)); + } + @Test public void getReceiptUnacknowledged() throws IOException { when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()