Handle 429s from play API and add subscription docs
This commit is contained in:
parent
0745cabc87
commit
80c11e7eda
|
@ -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<Response> deleteSubscriber(
|
||||
@Auth Optional<AuthenticatedDevice> 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<Response> updateSubscriber(
|
||||
@Auth Optional<AuthenticatedDevice> 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<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
|
||||
@Auth Optional<AuthenticatedDevice> 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<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
|
||||
@Auth Optional<AuthenticatedDevice> 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<Response> getSubscriptionInformation(
|
||||
@Auth Optional<AuthenticatedDevice> 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<Response> createSubscriptionReceiptCredentials(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
|
|
@ -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<SubscriptionException> {
|
||||
@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<Subscription
|
|||
}
|
||||
if (exception instanceof SubscriptionException.ProcessorException e) {
|
||||
return Response.status(PROCESSOR_ERROR_STATUS_CODE)
|
||||
.entity(Map.of(
|
||||
"processor", e.getProcessor().name(),
|
||||
"chargeFailure", e.getChargeFailure()
|
||||
))
|
||||
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.build();
|
||||
}
|
||||
if (exception instanceof SubscriptionException.ChargeFailurePaymentRequired e) {
|
||||
return Response
|
||||
.status(Response.Status.PAYMENT_REQUIRED)
|
||||
.entity(Map.of("chargeFailure", e.getChargeFailure()))
|
||||
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.build();
|
||||
}
|
||||
|
|
|
@ -102,16 +102,23 @@ public class SubscriptionException extends Exception {
|
|||
|
||||
public static class ChargeFailurePaymentRequired extends SubscriptionException {
|
||||
|
||||
private final PaymentProvider processor;
|
||||
private final ChargeFailure chargeFailure;
|
||||
|
||||
public ChargeFailurePaymentRequired(final ChargeFailure chargeFailure) {
|
||||
public ChargeFailurePaymentRequired(final PaymentProvider processor, final ChargeFailure chargeFailure) {
|
||||
super(null, null);
|
||||
this.processor = processor;
|
||||
this.chargeFailure = chargeFailure;
|
||||
}
|
||||
|
||||
public PaymentProvider getProcessor() {
|
||||
return processor;
|
||||
}
|
||||
|
||||
public ChargeFailure getChargeFailure() {
|
||||
return chargeFailure;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class ProcessorException extends SubscriptionException {
|
||||
|
|
|
@ -633,7 +633,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
|
|||
if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals(SubscriptionStatus.PAST_DUE)) {
|
||||
throw ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment());
|
||||
}
|
||||
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(transaction)));
|
||||
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction)));
|
||||
}
|
||||
|
||||
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();
|
||||
|
|
|
@ -43,6 +43,7 @@ import java.util.stream.Collectors;
|
|||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||
|
@ -362,10 +363,14 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
|
|||
|| 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()
|
||||
: "";
|
||||
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details, e);
|
||||
|
||||
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details);
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -645,7 +645,8 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
|
|||
|
||||
// If the charge object has a failure reason we can present to the user, create a detailed exception
|
||||
.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)
|
||||
.<SubscriptionException> map(charge -> new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(charge)))
|
||||
.<SubscriptionException> map(charge ->
|
||||
new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge)))
|
||||
|
||||
// Otherwise, return a generic payment required error
|
||||
.orElseGet(() -> new SubscriptionException.PaymentRequired())));
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue