Add support for one-time PayPal donations
This commit is contained in:
parent
d40d2389a9
commit
2ecbb18fe5
6
pom.xml
6
pom.xml
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# https://graphql.braintreepayments.com/reference/#Mutation--chargePaymentMethod
|
||||
mutation ChargePayPalOneTimePayment($input: ChargePaymentMethodInput!) {
|
||||
chargePaymentMethod(input: $input) {
|
||||
transaction {
|
||||
id,
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
# https://graphql.braintreepayments.com/reference/#Mutation--createPayPalOneTimePayment
|
||||
mutation CreatePayPalOneTimePayment($input: CreatePayPalOneTimePaymentInput!) {
|
||||
createPayPalOneTimePayment(input: $input) {
|
||||
approvalUrl,
|
||||
paymentId
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
// It’s a little counter-intuitive, but this compact constructor allows a default value
|
||||
// to be used when one isn’t specified (e.g. in YAML), allowing the field to still be
|
||||
// validated as @NotNull
|
||||
circuitBreaker = new CircuitBreakerConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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, Stripe’s 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(
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<>();
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue