Add support for one-time PayPal donations

This commit is contained in:
Chris Eager 2022-11-23 14:15:38 -06:00 committed by Chris Eager
parent d40d2389a9
commit 2ecbb18fe5
23 changed files with 35824 additions and 109 deletions

View File

@ -44,6 +44,7 @@
<properties>
<aws.sdk.version>1.12.287</aws.sdk.version>
<aws.sdk2.version>2.17.258</aws.sdk2.version>
<braintree.version>3.19.0</braintree.version>
<commons-codec.version>1.15</commons-codec.version>
<commons-csv.version>1.8</commons-csv.version>
<commons-io.version>2.9.0</commons-io.version>
@ -284,6 +285,11 @@
<artifactId>stripe-java</artifactId>
<version>${stripe.version}</version>
</dependency>
<dependency>
<groupId>com.braintreepayments.gateway</groupId>
<artifactId>braintree-java</artifactId>
<version>${braintree.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>

View File

@ -15,6 +15,25 @@ stripe:
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
boostDescription: >
Example
supportedCurrencies:
- xts
# - ...
# - Nth supported currency
braintree:
merchantId: unset
publicKey: unset
privateKey: unset
environment: unset
graphqlUrl: unset
merchantAccounts:
# ISO 4217 currency code and its corresponding sub-merchant account
'xts': unset
supportedCurrencies:
- xts
# - ...
# - Nth supported currency
dynamoDbClientConfiguration:
region: us-west-2 # AWS Region

View File

@ -457,6 +457,32 @@
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
</dependency>
<dependency>
<groupId>com.braintreepayments.gateway</groupId>
<artifactId>braintree-java</artifactId>
</dependency>
<dependency>
<groupId>com.apollographql.apollo3</groupId>
<artifactId>apollo-api-jvm</artifactId>
<version>3.6.2</version>
<exclusions>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-common</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<profiles>
@ -612,6 +638,31 @@
</arguments>
</configuration>
</plugin>
<plugin>
<groupId>com.github.aoudiamoncef</groupId>
<artifactId>apollo-client-maven-plugin</artifactId>
<version>5.0.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<services>
<braintree>
<compilationUnit>
<name>braintree</name>
<compilerParams>
<schemaPackageName>com.braintree.graphql.client</schemaPackageName>
</compilerParams>
</compilationUnit>
</braintree>
</services>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,9 @@
# https://graphql.braintreepayments.com/reference/#Mutation--chargePaymentMethod
mutation ChargePayPalOneTimePayment($input: ChargePaymentMethodInput!) {
chargePaymentMethod(input: $input) {
transaction {
id,
status
}
}
}

View File

@ -0,0 +1,7 @@
# https://graphql.braintreepayments.com/reference/#Mutation--createPayPalOneTimePayment
mutation CreatePayPalOneTimePayment($input: CreatePayPalOneTimePaymentInput!) {
createPayPalOneTimePayment(input: $input) {
approvalUrl,
paymentId
}
}

View File

@ -0,0 +1,8 @@
# https://graphql.braintreepayments.com/reference/#Mutation--tokenizePayPalOneTimePayment
mutation TokenizePayPalOneTimePayment($input: TokenizePayPalOneTimePaymentInput!) {
tokenizePayPalOneTimePayment(input: $input) {
paymentMethod {
id
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
@ -62,6 +63,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private StripeConfiguration stripe;
@NotNull
@Valid
@JsonProperty
private BraintreeConfiguration braintree;
@NotNull
@Valid
@JsonProperty
@ -259,6 +265,10 @@ public class WhisperServerConfiguration extends Configuration {
return stripe;
}
public BraintreeConfiguration getBraintree() {
return braintree;
}
public DynamoDbClientConfiguration getDynamoDbClientConfiguration() {
return dynamoDbClientConfiguration;
}

View File

@ -200,6 +200,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
@ -409,10 +410,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ExecutorService batchIdentityCheckExecutor = environment.lifecycle().executorService(name(getClass(), "batchIdentityCheck-%d")).minThreads(32).maxThreads(32).build();
ExecutorService multiRecipientMessageExecutor = environment.lifecycle()
.executorService(name(getClass(), "multiRecipientMessage-%d")).minThreads(64).maxThreads(64).build();
ExecutorService stripeExecutor = environment.lifecycle().executorService(name(getClass(), "stripe-%d")).
maxThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
minThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
allowCoreThreadTimeOut(true).
ExecutorService subscriptionProcessorExecutor = environment.lifecycle()
.executorService(name(getClass(), "subscriptionProcessor-%d"))
.maxThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
.minThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
.allowCoreThreadTimeOut(true).
build();
ExecutorService receiptSenderExecutor = environment.lifecycle()
.executorService(name(getClass(), "receiptSender-%d"))
@ -435,8 +437,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getAdminEventLoggingConfiguration().projectId(),
config.getAdminEventLoggingConfiguration().logName());
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
StripeManager stripeManager = new StripeManager(config.getStripe().apiKey(), subscriptionProcessorExecutor,
config.getStripe().idempotencyKeyGenerator(), config.getStripe().boostDescription(), config.getStripe()
.supportedCurrencies());
BraintreeManager braintreeManager = new BraintreeManager(config.getBraintree().merchantId(),
config.getBraintree().publicKey(), config.getBraintree().privateKey(), config.getBraintree().environment(),
config.getBraintree().supportedCurrencies(), config.getBraintree().merchantAccounts(),
config.getBraintree().graphqlUrl(), config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor);
ExternalServiceCredentialGenerator directoryCredentialsGenerator = new ExternalServiceCredentialGenerator(
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenSharedSecret(),
@ -686,7 +693,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
resourceBundleLevelTranslator));
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import java.util.Map;
import java.util.Set;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* @param merchantId the Braintree merchant ID
* @param publicKey the Braintree API public key
* @param privateKey the Braintree API private key
* @param environment the Braintree environment ("production" or "sandbox")
* @param supportedCurrencies the set of supported currencies
* @param graphqlUrl the Braintree GraphQL URl to use (this must match the environment)
* @param merchantAccounts merchant account within the merchant for processing individual currencies
* @param circuitBreaker configuration for the circuit breaker used by the GraphQL HTTP client
*/
public record BraintreeConfiguration(@NotBlank String merchantId,
@NotBlank String publicKey,
@NotBlank String privateKey,
@NotBlank String environment,
@NotEmpty Set<@NotBlank String> supportedCurrencies,
@NotBlank String graphqlUrl,
@NotEmpty Map<String, String> merchantAccounts,
@NotNull
@Valid
CircuitBreakerConfiguration circuitBreaker) {
public BraintreeConfiguration {
if (circuitBreaker == null) {
// Its a little counter-intuitive, but this compact constructor allows a default value
// to be used when one isnt specified (e.g. in YAML), allowing the field to still be
// validated as @NotNull
circuitBreaker = new CircuitBreakerConfiguration();
}
}
}

View File

@ -20,11 +20,11 @@ import org.whispersystems.textsecuregcm.util.ExactlySize;
* @param boosts the list of suggested one-time donation amounts
*/
public record OneTimeDonationCurrencyConfiguration(
@DecimalMin("0.01") BigDecimal minimum,
@DecimalMin("0.01") BigDecimal gift,
@NotNull @DecimalMin("0.01") BigDecimal minimum,
@NotNull @DecimalMin("0.01") BigDecimal gift,
@Valid
@ExactlySize(6)
@NotNull
List<@DecimalMin("0.01") BigDecimal> boosts) {
List<@NotNull @DecimalMin("0.01") BigDecimal> boosts) {
}

View File

@ -5,38 +5,13 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Set;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
public class StripeConfiguration {
public record StripeConfiguration(@NotBlank String apiKey,
@NotEmpty byte[] idempotencyKeyGenerator,
@NotBlank String boostDescription,
@NotEmpty Set<@NotBlank String> supportedCurrencies) {
private final String apiKey;
private final byte[] idempotencyKeyGenerator;
private final String boostDescription;
@JsonCreator
public StripeConfiguration(
@JsonProperty("apiKey") final String apiKey,
@JsonProperty("idempotencyKeyGenerator") final byte[] idempotencyKeyGenerator,
@JsonProperty("boostDescription") final String boostDescription) {
this.apiKey = apiKey;
this.idempotencyKeyGenerator = idempotencyKeyGenerator;
this.boostDescription = boostDescription;
}
@NotEmpty
public String getApiKey() {
return apiKey;
}
@NotEmpty
public byte[] getIdempotencyKeyGenerator() {
return idempotencyKeyGenerator;
}
@NotEmpty
public String getBoostDescription() {
return boostDescription;
}
}

View File

@ -94,6 +94,7 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
@ -111,6 +112,7 @@ public class SubscriptionController {
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final SubscriptionManager subscriptionManager;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager;
private final BadgeTranslator badgeTranslator;
@ -125,6 +127,7 @@ public class SubscriptionController {
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
@Nonnull SubscriptionManager subscriptionManager,
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
@Nonnull BadgeTranslator badgeTranslator,
@ -134,13 +137,14 @@ public class SubscriptionController {
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.subscriptionManager = Objects.requireNonNull(subscriptionManager);
this.stripeManager = Objects.requireNonNull(stripeManager);
this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration,
this.subscriptionConfiguration, List.of(stripeManager));
this.subscriptionConfiguration, List.of(stripeManager, braintreeManager));
}
private static Map<String, CurrencyConfiguration> buildCurrencyConfiguration(
@ -326,6 +330,14 @@ public class SubscriptionController {
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
return switch (paymentMethod) {
case CARD -> stripeManager;
case PAYPAL -> braintreeManager;
};
}
private SubscriptionProcessorManager getManagerForProcessor(SubscriptionProcessor processor) {
return switch (processor) {
case STRIPE -> stripeManager;
case BRAINTREE -> braintreeManager;
};
}
@ -682,11 +694,27 @@ public class SubscriptionController {
}
public static class CreateBoostRequest {
@NotEmpty @ExactlySize(3) public String currency;
@Min(1) public long amount;
@NotEmpty
@ExactlySize(3)
public String currency;
@Min(1)
public long amount;
public Long level;
}
public static class CreatePayPalBoostRequest extends CreateBoostRequest {
@NotEmpty
public String returnUrl;
@NotEmpty
public String cancelUrl;
}
record CreatePayPalBoostResponse(String approvalUrl, String paymentId) {
}
public static class CreateBoostResponse {
private final String clientSecret;
@ -723,22 +751,109 @@ public class SubscriptionController {
Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build());
}
}
BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
BigDecimal minCurrencyAmountMinorUnits = stripeManager.convertConfiguredAmountToStripeAmount(request.currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
.entity(Map.of("error", "amount_below_currency_minimum")).build());
}
validateRequestCurrencyAmount(request, amount, stripeManager);
})
.thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level))
.thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build());
}
/**
* Validates that the currency and amount in the request are supported by the {@code manager} and exceed the minimum
* permitted amount
*
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/
private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount,
SubscriptionProcessorManager manager) {
if (!manager.supportsCurrency(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
.entity(Map.of("error", "unsupported_currency")).build());
}
BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
BigDecimal minCurrencyAmountMinorUnits = stripeManager.convertConfiguredAmountToStripeAmount(request.currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
.entity(Map.of("error", "amount_below_currency_minimum")).build());
}
}
@Timed
@POST
@Path("/boost/paypal/create")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(@NotNull @Valid CreatePayPalBoostRequest request,
@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.runAsync(() -> {
if (request.level == null) {
request.level = oneTimeDonationConfiguration.boost().level();
}
validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager);
})
.thenCompose(unused -> {
final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
locale.toLanguageTag(),
request.returnUrl, request.cancelUrl);
})
.thenApply(approvalDetails -> Response.ok(
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
}
public static class ConfirmPayPalBoostRequest extends CreateBoostRequest {
@NotEmpty
public String payerId;
@NotEmpty
public String paymentId; // PAYID-
@NotEmpty
public String paymentToken; // EC-
}
record ConfirmPayPalBoostResponse(String paymentId) {
}
@Timed
@POST
@Path("/boost/paypal/confirm")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(@NotNull @Valid ConfirmPayPalBoostRequest request) {
return CompletableFuture.runAsync(() -> {
if (request.level == null) {
request.level = oneTimeDonationConfiguration.boost().level();
}
})
.thenCompose(unused -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId,
request.paymentToken, request.currency, request.amount, request.level))
.thenApply(chargeSuccessDetails -> Response.ok(
new ConfirmPayPalBoostResponse(chargeSuccessDetails.paymentId())).build());
}
public static class CreateBoostReceiptCredentialsRequest {
@NotNull public String paymentIntentId;
@NotNull public byte[] receiptCredentialRequest;
/**
* a payment ID from {@link #processor}
*/
@NotNull
public String paymentIntentId;
@NotNull
public byte[] receiptCredentialRequest;
@NotNull
public SubscriptionProcessor processor = SubscriptionProcessor.STRIPE;
}
public static class CreateBoostReceiptCredentialsResponse {
@ -762,26 +877,30 @@ public class SubscriptionController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(@NotNull @Valid CreateBoostReceiptCredentialsRequest request) {
return stripeManager.getPaymentIntent(request.paymentIntentId)
.thenCompose(paymentIntent -> {
if (paymentIntent == null) {
final SubscriptionProcessorManager manager = getManagerForProcessor(request.processor);
return manager.getPaymentDetails(request.paymentIntentId)
.thenCompose(paymentDetails -> {
if (paymentDetails == null) {
throw new WebApplicationException(Status.NOT_FOUND);
}
if (StringUtils.equalsIgnoreCase("processing", paymentIntent.getStatus())) {
throw new WebApplicationException(Status.NO_CONTENT);
}
if (!StringUtils.equalsIgnoreCase("succeeded", paymentIntent.getStatus())) {
throw new WebApplicationException(Status.PAYMENT_REQUIRED);
switch (paymentDetails.status()) {
case PROCESSING -> throw new WebApplicationException(Status.NO_CONTENT);
case SUCCEEDED -> {
}
default -> throw new WebApplicationException(Status.PAYMENT_REQUIRED);
}
long level = oneTimeDonationConfiguration.boost().level();
if (paymentIntent.getMetadata() != null) {
String levelMetadata = paymentIntent.getMetadata()
if (paymentDetails.customMetadata() != null) {
String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (NumberFormatException e) {
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentIntent.getId(), e);
paymentDetails.id(), e);
throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
}
}
@ -801,9 +920,10 @@ public class SubscriptionController {
throw new BadRequestException("invalid receipt credential request", e);
}
final long finalLevel = level;
return issuedReceiptsManager.recordIssuance(paymentIntent.getId(), receiptCredentialRequest, clock.instant())
return issuedReceiptsManager.recordIssuance(paymentDetails.id(), manager.getProcessor(),
receiptCredentialRequest, clock.instant())
.thenApply(unused -> {
Instant expiration = Instant.ofEpochSecond(paymentIntent.getCreated())
Instant expiration = paymentDetails.created()
.plus(levelExpiration)
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
@ -1052,7 +1172,8 @@ public class SubscriptionController {
return stripeManager.getLatestInvoiceForSubscription(record.subscriptionId)
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, record.subscriptionId))
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
receipt.getInvoiceLineItemId(), receiptCredentialRequest, requestData.now)
receipt.getInvoiceLineItemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest,
requestData.now)
.thenApply(unused -> receipt))
.thenApply(receipt -> {
ReceiptCredentialResponse receiptCredentialResponse;

View File

@ -26,14 +26,16 @@ import javax.crypto.spec.SecretKeySpec;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.core.Response.Status;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
public class IssuedReceiptsManager {
public static final String KEY_STRIPE_ID = "A"; // S (HashKey)
public static final String KEY_PROCESSOR_ITEM_ID = "A"; // S (HashKey)
public static final String KEY_ISSUED_RECEIPT_TAG = "B"; // B
public static final String KEY_EXPIRATION = "E"; // N
@ -54,29 +56,39 @@ public class IssuedReceiptsManager {
}
/**
* Returns a future that completes normally if either this stripe item was never issued a receipt credential
* Returns a future that completes normally if either this processor 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 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).
* <p>
* If this item has already been used to issue another receipt, throws a 409 conflict web application exception.
* <p>
* For {@link SubscriptionProcessor#STRIPE}, item is expected to refer to an invoice line item (subscriptions) or a
* payment intent (one-time).
*/
public CompletableFuture<Void> recordIssuance(
String stripeId,
String processorItemId,
SubscriptionProcessor processor,
ReceiptCredentialRequest request,
Instant now) {
final AttributeValue key;
if (processor == SubscriptionProcessor.STRIPE) {
// As the first processor, Stripes IDs were not prefixed. Its item IDs have documented prefixes (`il_`, `pi_`)
// that will not collide with `SubscriptionProcessor` names
key = s(processorItemId);
} else {
key = s(processor.name() + "_" + processorItemId);
}
UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()
.tableName(table)
.key(Map.of(KEY_STRIPE_ID, s(stripeId)))
.key(Map.of(KEY_PROCESSOR_ITEM_ID, key))
.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_STRIPE_ID,
"#key", KEY_PROCESSOR_ITEM_ID,
"#tag", KEY_ISSUED_RECEIPT_TAG,
"#exp", KEY_EXPIRATION))
.expressionAttributeValues(Map.of(

View File

@ -0,0 +1,236 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import com.apollographql.apollo3.api.ApolloResponse;
import com.apollographql.apollo3.api.Operation;
import com.apollographql.apollo3.api.Operations;
import com.apollographql.apollo3.api.Optional;
import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;
import com.braintree.graphql.client.type.ChargePaymentMethodInput;
import com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.CustomFieldInput;
import com.braintree.graphql.client.type.MonetaryAmountInput;
import com.braintree.graphql.client.type.PayPalExperienceProfileInput;
import com.braintree.graphql.client.type.PayPalIntent;
import com.braintree.graphql.client.type.PayPalLandingPageType;
import com.braintree.graphql.client.type.PayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.TransactionDescriptorInput;
import com.braintree.graphql.client.type.TransactionInput;
import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation;
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import javax.ws.rs.ServiceUnavailableException;
import okio.Buffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
class BraintreeGraphqlClient {
// required header value, recommended to be the date the integration began
// https://graphql.braintreepayments.com/guides/making_api_calls/#the-braintree-version-header
private static final String BRAINTREE_VERSION = "2022-10-01";
private static final Logger logger = LoggerFactory.getLogger(BraintreeGraphqlClient.class);
private final FaultTolerantHttpClient httpClient;
private final URI graphqlUri;
private final String authorizationHeader;
BraintreeGraphqlClient(final FaultTolerantHttpClient httpClient,
final String graphqlUri,
final String publicKey,
final String privateKey) {
this.httpClient = httpClient;
try {
this.graphqlUri = new URI(graphqlUri);
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid URI", e);
}
// public/private key is a bit of a misnomer, but we follow the upstream nomenclature
// they are used for Basic auth similar to client key/client secret credentials
this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((publicKey + ":" + privateKey).getBytes());
}
CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> createPayPalOneTimePayment(
final BigDecimal amount, final String currency, final String returnUrl,
final String cancelUrl, final String locale) {
final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl,
cancelUrl, locale);
final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse ->
{
// IntelliJ users: type parameters error no instance of type variable exists so that Data conforms to Data
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final CreatePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
return data.createPayPalOneTimePayment;
});
}
private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount,
String currency, String returnUrl, String cancelUrl, String locale) {
return new CreatePayPalOneTimePaymentInput(
Optional.absent(),
Optional.absent(), // merchant account ID will be specified when charging
new MonetaryAmountInput(amount.toString(), currency), // this could potentially use a CustomScalarAdapter
cancelUrl,
Optional.absent(),
PayPalIntent.SALE,
Optional.absent(),
Optional.present(false), // offerPayLater,
Optional.absent(),
Optional.present(
new PayPalExperienceProfileInput(Optional.present("Signal"),
Optional.present(false),
Optional.present(PayPalLandingPageType.LOGIN),
Optional.present(locale),
Optional.absent())),
Optional.absent(),
Optional.absent(),
returnUrl,
Optional.absent(),
Optional.absent()
);
}
CompletableFuture<TokenizePayPalOneTimePaymentMutation.TokenizePayPalOneTimePayment> tokenizePayPalOneTimePayment(
final String payerId, final String paymentId, final String paymentToken) {
final TokenizePayPalOneTimePaymentInput input = new TokenizePayPalOneTimePaymentInput(
Optional.absent(),
Optional.absent(), // merchant account ID will be specified when charging
new PayPalOneTimePaymentInput(payerId, paymentId, paymentToken)
);
final TokenizePayPalOneTimePaymentMutation mutation = new TokenizePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse -> {
// IntelliJ users: type parameters error no instance of type variable exists so that Data conforms to Data
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final TokenizePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
return data.tokenizePayPalOneTimePayment;
});
}
CompletableFuture<ChargePayPalOneTimePaymentMutation.ChargePaymentMethod> chargeOneTimePayment(
final String paymentMethodId, final BigDecimal amount, final String merchantAccount, final long level) {
final List<CustomFieldInput> customFields = List.of(
new CustomFieldInput("level", Optional.present(Long.toString(level))));
final ChargePaymentMethodInput input = buildChargePaymentMethodInput(paymentMethodId, amount, merchantAccount,
customFields);
final ChargePayPalOneTimePaymentMutation mutation = new ChargePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse -> {
// IntelliJ users: type parameters error no instance of type variable exists so that Data conforms to Data
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final ChargePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse,
mutation);
return data.chargePaymentMethod;
});
}
private static ChargePaymentMethodInput buildChargePaymentMethodInput(String paymentMethodId, BigDecimal amount,
String merchantAccount, List<CustomFieldInput> customFields) {
return new ChargePaymentMethodInput(
Optional.absent(),
paymentMethodId,
new TransactionInput(
// documented as amount: whole number, or exactly two or three decimal places
amount.toString(), // this could potentially use a CustomScalarAdapter
Optional.present(merchantAccount),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.present(customFields),
Optional.present(new TransactionDescriptorInput(
Optional.present("Signal Technology Foundation"),
Optional.absent(),
Optional.absent()
)),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent()
)
);
}
/**
* Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise
* throws a {@link ServiceUnavailableException}.
*/
private <T extends Operation<U>, U extends Operation.Data> U assertSuccessAndExtractData(
HttpResponse<String> httpResponse, T operation) {
if (httpResponse.statusCode() != 200) {
logger.warn("Received HTTP response status {} ({})", httpResponse.statusCode(),
httpResponse.headers().firstValue("paypal-debug-id").orElse("<debug id absent>"));
throw new ServiceUnavailableException();
}
ApolloResponse<U> response = Operations.parseJsonResponse(operation, httpResponse.body());
if (response.hasErrors() || response.data == null) {
//noinspection ConstantConditions
response.errors.forEach(
error -> {
final Object legacyCode = java.util.Optional.ofNullable(error.getExtensions())
.map(extensions -> extensions.get("legacyCode"))
.orElse("<none>");
logger.warn("Received GraphQL error for {}: \"{}\" (legacyCode: {})",
response.operation.name(), error.getMessage(), legacyCode);
});
throw new ServiceUnavailableException();
}
return response.data;
}
private HttpRequest buildRequest(final Operation<?> operation) {
final Buffer buffer = new Buffer();
Operations.composeJsonRequest(operation, new BufferedSinkJsonWriter(buffer));
return HttpRequest.newBuilder()
.uri(graphqlUri)
.method("POST", HttpRequest.BodyPublishers.ofString(buffer.readUtf8()))
.header("Content-Type", "application/json")
.header("Authorization", authorizationHeader)
.header("Braintree-Version", BRAINTREE_VERSION)
.build();
}
}

View File

@ -0,0 +1,206 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import com.braintreegateway.BraintreeGateway;
import com.braintreegateway.ResourceCollection;
import com.braintreegateway.Transaction;
import com.braintreegateway.TransactionSearchRequest;
import com.braintreegateway.exceptions.NotFoundException;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
public class BraintreeManager implements SubscriptionProcessorManager {
private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094";
private final BraintreeGateway braintreeGateway;
private final BraintreeGraphqlClient braintreeGraphqlClient;
private final Executor executor;
private final Set<String> supportedCurrencies;
private final Map<String, String> currenciesToMerchantAccounts;
public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey,
final String braintreePrivateKey,
final String braintreeEnvironment,
final Set<String> supportedCurrencies,
final Map<String, String> currenciesToMerchantAccounts,
final String graphqlUri,
final CircuitBreakerConfiguration circuitBreakerConfiguration,
final Executor executor) {
this.braintreeGateway = new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey,
braintreePrivateKey);
this.supportedCurrencies = supportedCurrencies;
this.currenciesToMerchantAccounts = currenciesToMerchantAccounts;
final FaultTolerantHttpClient httpClient = FaultTolerantHttpClient.newBuilder()
.withName("braintree-graphql")
.withCircuitBreaker(circuitBreakerConfiguration)
.withExecutor(executor)
.build();
this.braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, graphqlUri, braintreePublicKey,
braintreePrivateKey);
this.executor = executor;
}
@Override
public Set<String> getSupportedCurrencies() {
return supportedCurrencies;
}
@Override
public SubscriptionProcessor getProcessor() {
return SubscriptionProcessor.BRAINTREE;
}
@Override
public boolean supportsPaymentMethod(final PaymentMethod paymentMethod) {
return paymentMethod == PaymentMethod.PAYPAL;
}
@Override
public boolean supportsCurrency(final String currency) {
return supportedCurrencies.contains(currency.toLowerCase(Locale.ROOT));
}
@Override
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
return new PaymentDetails(transaction.getGraphQLId(),
transaction.getCustomFields(),
getPaymentStatus(transaction.getStatus()),
transaction.getCreatedAt().toInstant());
} catch (final NotFoundException e) {
return null;
}
}, executor);
}
@Override
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) {
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
}
@Override
public CompletableFuture<String> createPaymentMethodSetupToken(final String customerId) {
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
}
public CompletableFuture<PayPalOneTimePaymentApprovalDetails> createOneTimePayment(String currency, long amount,
String locale, String returnUrl, String cancelUrl) {
return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount),
currency.toUpperCase(Locale.ROOT), returnUrl,
cancelUrl, locale)
.thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId));
}
public CompletableFuture<PayPalChargeSuccessDetails> captureOneTimePayment(String payerId, String paymentId,
String paymentToken, String currency, long amount, long level) {
return braintreeGraphqlClient.tokenizePayPalOneTimePayment(payerId, paymentId, paymentToken)
.thenCompose(response -> braintreeGraphqlClient.chargeOneTimePayment(
response.paymentMethod.id,
convertApiAmountToBraintreeAmount(currency, amount),
currenciesToMerchantAccounts.get(currency.toLowerCase(Locale.ROOT)),
level)
.thenComposeAsync(chargeResponse -> {
final PaymentStatus paymentStatus = getPaymentStatus(chargeResponse.transaction.status);
if (paymentStatus == PaymentStatus.SUCCEEDED || paymentStatus == PaymentStatus.PROCESSING) {
return CompletableFuture.completedFuture(new PayPalChargeSuccessDetails(chargeResponse.transaction.id));
}
// the GraphQL/Apollo interfaces are a tad unwieldy for this type of status checking
final Transaction unsuccessfulTx = braintreeGateway.transaction().find(chargeResponse.transaction.id);
if (PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE.equals(unsuccessfulTx.getProcessorResponseCode())
|| Transaction.GatewayRejectionReason.DUPLICATE.equals(
unsuccessfulTx.getGatewayRejectionReason())) {
// the payment has already been charged - maybe a previous call timed out or was interrupted -
// in any case, check for a successful transaction with the paymentId
final ResourceCollection<Transaction> search = braintreeGateway.transaction()
.search(new TransactionSearchRequest()
.paypalPaymentId().is(paymentId)
.status().in(
Transaction.Status.SETTLED,
Transaction.Status.SETTLING,
Transaction.Status.SUBMITTED_FOR_SETTLEMENT,
Transaction.Status.SETTLEMENT_PENDING
)
);
if (search.getMaximumSize() == 0) {
return CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
}
final Transaction successfulTx = search.getFirst();
return CompletableFuture.completedFuture(
new PayPalChargeSuccessDetails(successfulTx.getGraphQLId()));
}
logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode());
return CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
}, executor));
}
private static PaymentStatus getPaymentStatus(Transaction.Status status) {
return switch (status) {
case SETTLEMENT_CONFIRMED, SETTLING, SUBMITTED_FOR_SETTLEMENT, SETTLED -> PaymentStatus.SUCCEEDED;
case AUTHORIZATION_EXPIRED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLEMENT_DECLINED, VOIDED, FAILED ->
PaymentStatus.FAILED;
default -> PaymentStatus.UNKNOWN;
};
}
private static PaymentStatus getPaymentStatus(com.braintree.graphql.client.type.PaymentStatus status) {
try {
Transaction.Status transactionStatus = Transaction.Status.valueOf(status.rawValue);
return getPaymentStatus(transactionStatus);
} catch (final Exception e) {
return PaymentStatus.UNKNOWN;
}
}
private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {
return switch (currency.toLowerCase(Locale.ROOT)) {
// JPY is the only supported zero-decimal currency
case "jpy" -> BigDecimal.valueOf(amount);
default -> BigDecimal.valueOf(amount).scaleByPowerOfTen(-2);
};
}
public record PayPalOneTimePaymentApprovalDetails(String approvalUrl, String paymentId) {
}
public record PayPalChargeSuccessDetails(String paymentId) {
}
}

View File

@ -10,4 +10,8 @@ public enum PaymentMethod {
* A credit card or debit card, including those from Apple Pay and Google Pay
*/
CARD,
/**
* A PayPal account
*/
PAYPAL,
}

View File

@ -42,6 +42,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@ -66,28 +67,18 @@ public class StripeManager implements SubscriptionProcessorManager {
private static final String METADATA_KEY_LEVEL = "level";
// https://stripe.com/docs/currencies?presentment-currency=US
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
"aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd",
"bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf",
"dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd",
"hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd",
"kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr",
"mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln",
"pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std",
"szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst",
"xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw");
private final String apiKey;
private final Executor executor;
private final byte[] idempotencyKeyGenerator;
private final String boostDescription;
private final Set<String> supportedCurrencies;
public StripeManager(
@Nonnull String apiKey,
@Nonnull Executor executor,
@Nonnull byte[] idempotencyKeyGenerator,
@Nonnull String boostDescription) {
@Nonnull String boostDescription,
@Nonnull Set<String> supportedCurrencies) {
this.apiKey = Objects.requireNonNull(apiKey);
if (Strings.isNullOrEmpty(apiKey)) {
throw new IllegalArgumentException("apiKey cannot be empty");
@ -98,6 +89,7 @@ public class StripeManager implements SubscriptionProcessorManager {
throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty");
}
this.boostDescription = Objects.requireNonNull(boostDescription);
this.supportedCurrencies = supportedCurrencies;
}
@Override
@ -110,6 +102,11 @@ public class StripeManager implements SubscriptionProcessorManager {
return paymentMethod == PaymentMethod.CARD;
}
@Override
public boolean supportsCurrency(final String currency) {
return supportedCurrencies.contains(currency);
}
private RequestOptions commonOptions() {
return commonOptions(null);
}
@ -181,7 +178,7 @@ public class StripeManager implements SubscriptionProcessorManager {
@Override
public Set<String> getSupportedCurrencies() {
return SUPPORTED_CURRENCIES;
return supportedCurrencies;
}
/**
@ -210,10 +207,15 @@ public class StripeManager implements SubscriptionProcessorManager {
}, executor);
}
public CompletableFuture<PaymentIntent> getPaymentIntent(String paymentIntentId) {
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
return CompletableFuture.supplyAsync(() -> {
try {
return PaymentIntent.retrieve(paymentIntentId, commonOptions());
final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, commonOptions());
return new PaymentDetails(paymentIntent.getId(),
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
getPaymentStatusForStatus(paymentIntent.getStatus()),
Instant.ofEpochSecond(paymentIntent.getCreated()));
} catch (StripeException e) {
if (e.getStatusCode() == 404) {
return null;
@ -224,7 +226,16 @@ public class StripeManager implements SubscriptionProcessorManager {
}, executor);
}
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) {
private static PaymentStatus getPaymentStatusForStatus(String status) {
return switch (status.toLowerCase(Locale.ROOT)) {
case "processing" -> PaymentStatus.PROCESSING;
case "succeeded" -> PaymentStatus.SUCCEEDED;
default -> PaymentStatus.UNKNOWN;
};
}
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level,
long lastSubscriptionCreatedAt) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
.setCustomer(customerId)

View File

@ -16,6 +16,7 @@ public enum SubscriptionProcessor {
// because provider IDs are stored, they should not be reused, and great care
// must be used if a provider is removed from the list
STRIPE(1),
BRAINTREE(2),
;
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();

View File

@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.subscriptions;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@ -14,9 +16,27 @@ public interface SubscriptionProcessorManager {
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
boolean supportsCurrency(String currency);
Set<String> getSupportedCurrencies();
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
Set<String> getSupportedCurrencies();
record PaymentDetails(String id,
Map<String, String> customMetadata,
PaymentStatus status,
Instant created) {
}
enum PaymentStatus {
SUCCEEDED,
PROCESSING,
FAILED,
UNKNOWN,
}
}

View File

@ -19,8 +19,8 @@ import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.stripe.exception.ApiException;
import com.stripe.model.Subscription;
import com.stripe.model.PaymentIntent;
import com.stripe.model.Subscription;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
@ -61,7 +61,7 @@ import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
@ -87,18 +87,21 @@ class SubscriptionControllerTest {
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
static {
// this behavior is required by the SubscriptionController constructor
when(STRIPE_MANAGER.getSupportedCurrencies())
.thenCallRealMethod();
when(STRIPE_MANAGER.supportsPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif"));
when(STRIPE_MANAGER.supportsPaymentMethod(any()))
.thenCallRealMethod();
}
private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class);
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS,
CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS,
ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
@ -157,8 +160,9 @@ class SubscriptionControllerTest {
@Test
void testCreateBoostPaymentIntent() {
when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(300));
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong()))
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
String clientSecret = "some_client_secret";
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
@ -168,7 +172,7 @@ class SubscriptionControllerTest {
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void createBoostReceiptInvalid() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")

View File

@ -19,6 +19,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
@ -31,9 +32,9 @@ class IssuedReceiptsManagerTest {
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(ISSUED_RECEIPTS_TABLE_NAME)
.hashKey(IssuedReceiptsManager.KEY_STRIPE_ID)
.hashKey(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(IssuedReceiptsManager.KEY_STRIPE_ID)
.attributeName(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
.attributeType(ScalarAttributeType.S)
.build())
.build();
@ -59,18 +60,21 @@ class IssuedReceiptsManagerTest {
byte[] request1 = new byte[20];
SECURE_RANDOM.nextBytes(request1);
when(receiptCredentialRequest.serialize()).thenReturn(request1);
CompletableFuture<Void> future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
CompletableFuture<Void> future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE,
receiptCredentialRequest, now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
// same request should succeed
future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
// same item with new request should fail
byte[] request2 = new byte[20];
SECURE_RANDOM.nextBytes(request2);
when(receiptCredentialRequest.serialize()).thenReturn(request2);
future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).failsWithin(Duration.ofSeconds(3)).
withThrowableOfType(Throwable.class).
havingCause().
@ -80,7 +84,8 @@ class IssuedReceiptsManagerTest {
"status 409"));
// different item with new request should be okay though
future = issuedReceiptsManager.recordIssuance("item-2", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-2", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
}
}

View File

@ -0,0 +1,174 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
import java.math.BigDecimal;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import javax.ws.rs.ServiceUnavailableException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
class BraintreeGraphqlClientTest {
private static final String CURRENCY = "xts";
private static final String RETURN_URL = "https://example.com/return";
private static final String CANCEL_URL = "https://example.com/cancel";
private static final String LOCALE = "xx";
private FaultTolerantHttpClient httpClient;
private BraintreeGraphqlClient braintreeGraphqlClient;
@BeforeEach
void setUp() {
httpClient = mock(FaultTolerantHttpClient.class);
braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, "https://example.com", "public", "super-secret");
}
@Test
void createPayPalOneTimePayment() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
final String paymentId = "PAYID-AAA1AAAA1A11111AA111111A";
when(response.body())
.thenReturn(createPayPalOneTimePaymentResponse(paymentId));
when(response.statusCode())
.thenReturn(200);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment result = future.get();
assertEquals(paymentId, result.paymentId);
assertNotNull(result.approvalUrl);
});
}
@Test
void createPayPalOneTimePaymentHttpError() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
when(response.statusCode())
.thenReturn(500);
final HttpHeaders httpheaders = mock(HttpHeaders.class);
when(httpheaders.firstValue(any())).thenReturn(Optional.empty());
when(response.headers())
.thenReturn(httpheaders);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final ExecutionException e = assertThrows(ExecutionException.class, future::get);
assertTrue(e.getCause() instanceof ServiceUnavailableException);
});
}
@Test
void createPayPalOneTimePaymentGraphQlError() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
when(response.body())
.thenReturn(createErrorResponse("createPayPalOneTimePayment", "12345"));
when(response.statusCode())
.thenReturn(200);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final ExecutionException e = assertThrows(ExecutionException.class, future::get);
assertTrue(e.getCause() instanceof ServiceUnavailableException);
});
}
private String createPayPalOneTimePaymentResponse(final String paymentId) {
final String cannedToken = "EC-1AA11111AA111111A";
return String.format("""
{
"data": {
"createPayPalOneTimePayment": {
"approvalUrl": "https://www.sandbox.paypal.com/checkoutnow?nolegacy=1&token=%2$s",
"paymentId": "%1$s"
}
},
"extensions": {
"requestId": "%3$s"
}
}
""", paymentId, cannedToken, UUID.randomUUID());
}
private String createErrorResponse(final String operationName, final String legacyCode) {
return String.format("""
{
"data": {
"%1$s": null
},
"errors": [ {
"message": "This is a test error message.",
"locations": [ {
"line": 2,
"column": 7
} ],
"path": [ "%1$s" ],
"extensions": {
"errorType": "user_error",
"errorClass": "VALIDATION",
"legacyCode": "%2$s",
"inputPath": [ "input", "testField" ]
}
}],
"extensions": {
"requestId": "%3$s"
}
}
""", operationName, legacyCode, UUID.randomUUID());
}
@Test
void tokenizePayPalOneTimePayment() {
}
@Test
void chargeOneTimePayment() {
}
}