From 090d722b618e01c75bf6117ee6ca3383a2b42d24 Mon Sep 17 00:00:00 2001 From: Ehren Kret Date: Mon, 25 Oct 2021 14:54:40 -0700 Subject: [PATCH] Add method to retrieve receipt credentials for a boost payment --- .../configuration/BoostConfiguration.java | 16 ++++ .../controllers/SubscriptionController.java | 77 +++++++++++++++++++ .../storage/IssuedReceiptsManager.java | 14 ++-- .../textsecuregcm/stripe/StripeManager.java | 14 ++++ .../storage/IssuedReceiptsManagerTest.java | 4 +- 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BoostConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BoostConfiguration.java index e2dbd8fb6..9a4cb010c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BoostConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BoostConfiguration.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.configuration; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import java.math.BigDecimal; +import java.time.Duration; import java.util.List; import java.util.Map; import javax.validation.Valid; @@ -18,14 +19,29 @@ import org.whispersystems.textsecuregcm.util.ExactlySize; public class BoostConfiguration { + private final long level; + private final Duration expiration; private final Map> currencies; @JsonCreator public BoostConfiguration( + @JsonProperty("level") long level, + @JsonProperty("expiration") Duration expiration, @JsonProperty("currencies") final Map> currencies) { + this.level = level; + this.expiration = expiration; this.currencies = currencies; } + public long getLevel() { + return level; + } + + @NotNull + public Duration getExpiration() { + return expiration; + } + @Valid @NotNull public Map<@NotEmpty String, @Valid @ExactlySize(6) List<@DecimalMin("0.01") @NotNull BigDecimal>> getCurrencies() { 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 62cd0f8ed..2b6e6a6a6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -50,6 +50,7 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.ProcessingException; import javax.ws.rs.Produces; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -460,6 +461,82 @@ public class SubscriptionController { .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); } + public static class CreateBoostReceiptCredentialsRequest { + + private final String paymentIntentId; + private final byte[] receiptCredentialRequest; + + @JsonCreator + public CreateBoostReceiptCredentialsRequest( + @JsonProperty("paymentIntentId") String paymentIntentId, + @JsonProperty("receiptCredentialRequest") byte[] receiptCredentialRequest) { + this.paymentIntentId = paymentIntentId; + this.receiptCredentialRequest = receiptCredentialRequest; + } + + public String getPaymentIntentId() { + return paymentIntentId; + } + + public byte[] getReceiptCredentialRequest() { + return receiptCredentialRequest; + } + } + + public static class CreateBoostReceiptCredentialsResponse { + + private final byte[] receiptCredentialResponse; + + @JsonCreator + public CreateBoostReceiptCredentialsResponse( + @JsonProperty("receiptCredentialResponse") byte[] receiptCredentialResponse) { + this.receiptCredentialResponse = receiptCredentialResponse; + } + + public byte[] getReceiptCredentialResponse() { + return receiptCredentialResponse; + } + } + + @Timed + @POST + @Path("/boost/receipt_credentials") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture createBoostReceiptCredentials(CreateBoostReceiptCredentialsRequest request) { + return stripeManager.getPaymentIntent(request.getPaymentIntentId()) + .thenCompose(paymentIntent -> { + if (paymentIntent == null) { + throw new WebApplicationException(Status.NO_CONTENT); + } + if (!"succeeded".equalsIgnoreCase(paymentIntent.getStatus())) { + throw new WebApplicationException(Status.NO_CONTENT); + } + ReceiptCredentialRequest receiptCredentialRequest; + try { + receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest()); + } catch (InvalidInputException e) { + throw new BadRequestException("invalid receipt credential request", e); + } + return issuedReceiptsManager.recordIssuance(paymentIntent.getId(), receiptCredentialRequest, clock.instant()) + .thenApply(unused -> { + Instant expiration = Instant.ofEpochSecond(paymentIntent.getCreated()) + .plus(boostConfiguration.getExpiration()) + .truncatedTo(ChronoUnit.DAYS) + .plus(1, ChronoUnit.DAYS); + ReceiptCredentialResponse receiptCredentialResponse; + try { + receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( + receiptCredentialRequest, expiration.getEpochSecond(), boostConfiguration.getLevel()); + } catch (VerificationFailedException e) { + throw new BadRequestException("receipt credential request failed verification", e); + } + return Response.ok(new CreateBoostReceiptCredentialsResponse(receiptCredentialResponse.serialize())) + .build(); + }); + }); + } + public static class GetSubscriptionInformationResponse { public static class Subscription { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java index 65a50292b..2726041e8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java @@ -33,7 +33,7 @@ import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; public class IssuedReceiptsManager { - public static final String KEY_INVOICE_LINE_ITEM_ID = "A"; // S (HashKey) + public static final String KEY_STRIPE_ID = "A"; // S (HashKey) public static final String KEY_ISSUED_RECEIPT_TAG = "B"; // B public static final String KEY_EXPIRATION = "E"; // N @@ -54,27 +54,29 @@ public class IssuedReceiptsManager { } /** - * Returns a future that completes normally if either this invoice line item was never issued a receipt credential + * Returns a future that completes normally if either this stripe item was never issued a receipt credential * previously OR if it was issued a receipt credential previously for the exact same receipt credential request * enabling clients to retry in case they missed the original response. * - * If this invoice line item id has already been used to issue another receipt, throws a 409 conflict web application + * If this stripe item has already been used to issue another receipt, throws a 409 conflict web application * exception. + * + * Stripe item is expected to refer to an invoice line item (subscriptions) or a payment intent (one-time). */ public CompletableFuture recordIssuance( - String invoiceLineItemId, + String stripeId, ReceiptCredentialRequest request, Instant now) { UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() .tableName(table) - .key(Map.of(KEY_INVOICE_LINE_ITEM_ID, s(invoiceLineItemId))) + .key(Map.of(KEY_STRIPE_ID, s(stripeId))) .conditionExpression("attribute_not_exists(#key) OR #tag = :tag") .returnValues(ReturnValue.NONE) .updateExpression("SET " + "#tag = if_not_exists(#tag, :tag), " + "#exp = if_not_exists(#exp, :exp)") .expressionAttributeNames(Map.of( - "#key", KEY_INVOICE_LINE_ITEM_ID, + "#key", KEY_STRIPE_ID, "#tag", KEY_ISSUED_RECEIPT_TAG, "#exp", KEY_EXPIRATION)) .expressionAttributeValues(Map.of( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/stripe/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/stripe/StripeManager.java index c93fab153..c2a074b28 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/stripe/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/stripe/StripeManager.java @@ -157,6 +157,20 @@ public class StripeManager { }, executor); } + public CompletableFuture getPaymentIntent(String paymentIntentId) { + return CompletableFuture.supplyAsync(() -> { + try { + return PaymentIntent.retrieve(paymentIntentId, commonOptions()); + } catch (StripeException e) { + if (e.getStatusCode() == 404) { + return null; + } else { + throw new CompletionException(e); + } + } + }, executor); + } + public CompletableFuture createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) { return CompletableFuture.supplyAsync(() -> { SubscriptionCreateParams params = SubscriptionCreateParams.builder() diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java index f2cb25aa7..2092516dd 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java @@ -31,9 +31,9 @@ class IssuedReceiptsManagerTest { @RegisterExtension static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() .tableName(ISSUED_RECEIPTS_TABLE_NAME) - .hashKey(IssuedReceiptsManager.KEY_INVOICE_LINE_ITEM_ID) + .hashKey(IssuedReceiptsManager.KEY_STRIPE_ID) .attributeDefinition(AttributeDefinition.builder() - .attributeName(IssuedReceiptsManager.KEY_INVOICE_LINE_ITEM_ID) + .attributeName(IssuedReceiptsManager.KEY_STRIPE_ID) .attributeType(ScalarAttributeType.S) .build()) .build();