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>
|
<properties>
|
||||||
<aws.sdk.version>1.12.287</aws.sdk.version>
|
<aws.sdk.version>1.12.287</aws.sdk.version>
|
||||||
<aws.sdk2.version>2.17.258</aws.sdk2.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-codec.version>1.15</commons-codec.version>
|
||||||
<commons-csv.version>1.8</commons-csv.version>
|
<commons-csv.version>1.8</commons-csv.version>
|
||||||
<commons-io.version>2.9.0</commons-io.version>
|
<commons-io.version>2.9.0</commons-io.version>
|
||||||
|
@ -284,6 +285,11 @@
|
||||||
<artifactId>stripe-java</artifactId>
|
<artifactId>stripe-java</artifactId>
|
||||||
<version>${stripe.version}</version>
|
<version>${stripe.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.braintreepayments.gateway</groupId>
|
||||||
|
<artifactId>braintree-java</artifactId>
|
||||||
|
<version>${braintree.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.code.gson</groupId>
|
<groupId>com.google.code.gson</groupId>
|
||||||
<artifactId>gson</artifactId>
|
<artifactId>gson</artifactId>
|
||||||
|
|
|
@ -15,6 +15,25 @@ stripe:
|
||||||
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
||||||
boostDescription: >
|
boostDescription: >
|
||||||
Example
|
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:
|
dynamoDbClientConfiguration:
|
||||||
region: us-west-2 # AWS Region
|
region: us-west-2 # AWS Region
|
||||||
|
|
|
@ -457,6 +457,32 @@
|
||||||
<groupId>com.stripe</groupId>
|
<groupId>com.stripe</groupId>
|
||||||
<artifactId>stripe-java</artifactId>
|
<artifactId>stripe-java</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<profiles>
|
<profiles>
|
||||||
|
@ -612,6 +638,31 @@
|
||||||
</arguments>
|
</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</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>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</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.AppConfigConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
||||||
|
@ -62,6 +63,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private StripeConfiguration stripe;
|
private StripeConfiguration stripe;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Valid
|
||||||
|
@JsonProperty
|
||||||
|
private BraintreeConfiguration braintree;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
|
@ -259,6 +265,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return stripe;
|
return stripe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BraintreeConfiguration getBraintree() {
|
||||||
|
return braintree;
|
||||||
|
}
|
||||||
|
|
||||||
public DynamoDbClientConfiguration getDynamoDbClientConfiguration() {
|
public DynamoDbClientConfiguration getDynamoDbClientConfiguration() {
|
||||||
return dynamoDbClientConfiguration;
|
return dynamoDbClientConfiguration;
|
||||||
}
|
}
|
||||||
|
|
|
@ -200,6 +200,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
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 batchIdentityCheckExecutor = environment.lifecycle().executorService(name(getClass(), "batchIdentityCheck-%d")).minThreads(32).maxThreads(32).build();
|
||||||
ExecutorService multiRecipientMessageExecutor = environment.lifecycle()
|
ExecutorService multiRecipientMessageExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "multiRecipientMessage-%d")).minThreads(64).maxThreads(64).build();
|
.executorService(name(getClass(), "multiRecipientMessage-%d")).minThreads(64).maxThreads(64).build();
|
||||||
ExecutorService stripeExecutor = environment.lifecycle().executorService(name(getClass(), "stripe-%d")).
|
ExecutorService subscriptionProcessorExecutor = environment.lifecycle()
|
||||||
maxThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
|
.executorService(name(getClass(), "subscriptionProcessor-%d"))
|
||||||
minThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
|
.maxThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||||
allowCoreThreadTimeOut(true).
|
.minThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||||
|
.allowCoreThreadTimeOut(true).
|
||||||
build();
|
build();
|
||||||
ExecutorService receiptSenderExecutor = environment.lifecycle()
|
ExecutorService receiptSenderExecutor = environment.lifecycle()
|
||||||
.executorService(name(getClass(), "receiptSender-%d"))
|
.executorService(name(getClass(), "receiptSender-%d"))
|
||||||
|
@ -435,8 +437,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
config.getAdminEventLoggingConfiguration().projectId(),
|
config.getAdminEventLoggingConfiguration().projectId(),
|
||||||
config.getAdminEventLoggingConfiguration().logName());
|
config.getAdminEventLoggingConfiguration().logName());
|
||||||
|
|
||||||
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
|
StripeManager stripeManager = new StripeManager(config.getStripe().apiKey(), subscriptionProcessorExecutor,
|
||||||
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
|
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(
|
ExternalServiceCredentialGenerator directoryCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||||
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenSharedSecret(),
|
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenSharedSecret(),
|
||||||
|
@ -686,7 +693,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
);
|
);
|
||||||
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
||||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||||
subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
|
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
|
||||||
resourceBundleLevelTranslator));
|
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
|
* @param boosts the list of suggested one-time donation amounts
|
||||||
*/
|
*/
|
||||||
public record OneTimeDonationCurrencyConfiguration(
|
public record OneTimeDonationCurrencyConfiguration(
|
||||||
@DecimalMin("0.01") BigDecimal minimum,
|
@NotNull @DecimalMin("0.01") BigDecimal minimum,
|
||||||
@DecimalMin("0.01") BigDecimal gift,
|
@NotNull @DecimalMin("0.01") BigDecimal gift,
|
||||||
@Valid
|
@Valid
|
||||||
@ExactlySize(6)
|
@ExactlySize(6)
|
||||||
@NotNull
|
@NotNull
|
||||||
List<@DecimalMin("0.01") BigDecimal> boosts) {
|
List<@NotNull @DecimalMin("0.01") BigDecimal> boosts) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,38 +5,13 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
import java.util.Set;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
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.IssuedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
|
@ -111,6 +112,7 @@ public class SubscriptionController {
|
||||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
|
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
|
||||||
private final SubscriptionManager subscriptionManager;
|
private final SubscriptionManager subscriptionManager;
|
||||||
private final StripeManager stripeManager;
|
private final StripeManager stripeManager;
|
||||||
|
private final BraintreeManager braintreeManager;
|
||||||
private final ServerZkReceiptOperations zkReceiptOperations;
|
private final ServerZkReceiptOperations zkReceiptOperations;
|
||||||
private final IssuedReceiptsManager issuedReceiptsManager;
|
private final IssuedReceiptsManager issuedReceiptsManager;
|
||||||
private final BadgeTranslator badgeTranslator;
|
private final BadgeTranslator badgeTranslator;
|
||||||
|
@ -125,6 +127,7 @@ public class SubscriptionController {
|
||||||
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||||
@Nonnull SubscriptionManager subscriptionManager,
|
@Nonnull SubscriptionManager subscriptionManager,
|
||||||
@Nonnull StripeManager stripeManager,
|
@Nonnull StripeManager stripeManager,
|
||||||
|
@Nonnull BraintreeManager braintreeManager,
|
||||||
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
|
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
|
||||||
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
|
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
|
||||||
@Nonnull BadgeTranslator badgeTranslator,
|
@Nonnull BadgeTranslator badgeTranslator,
|
||||||
|
@ -134,13 +137,14 @@ public class SubscriptionController {
|
||||||
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
|
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
|
||||||
this.subscriptionManager = Objects.requireNonNull(subscriptionManager);
|
this.subscriptionManager = Objects.requireNonNull(subscriptionManager);
|
||||||
this.stripeManager = Objects.requireNonNull(stripeManager);
|
this.stripeManager = Objects.requireNonNull(stripeManager);
|
||||||
|
this.braintreeManager = Objects.requireNonNull(braintreeManager);
|
||||||
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
|
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
|
||||||
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
|
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
|
||||||
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
|
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
|
||||||
this.levelTranslator = Objects.requireNonNull(levelTranslator);
|
this.levelTranslator = Objects.requireNonNull(levelTranslator);
|
||||||
|
|
||||||
this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration,
|
this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration,
|
||||||
this.subscriptionConfiguration, List.of(stripeManager));
|
this.subscriptionConfiguration, List.of(stripeManager, braintreeManager));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, CurrencyConfiguration> buildCurrencyConfiguration(
|
private static Map<String, CurrencyConfiguration> buildCurrencyConfiguration(
|
||||||
|
@ -326,6 +330,14 @@ public class SubscriptionController {
|
||||||
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
||||||
return switch (paymentMethod) {
|
return switch (paymentMethod) {
|
||||||
case CARD -> stripeManager;
|
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 {
|
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 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 {
|
public static class CreateBoostResponse {
|
||||||
|
|
||||||
private final String clientSecret;
|
private final String clientSecret;
|
||||||
|
@ -723,22 +751,109 @@ public class SubscriptionController {
|
||||||
Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build());
|
Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
|
validateRequestCurrencyAmount(request, amount, stripeManager);
|
||||||
.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());
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level))
|
.thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level))
|
||||||
.thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build());
|
.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 {
|
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 {
|
public static class CreateBoostReceiptCredentialsResponse {
|
||||||
|
@ -762,26 +877,30 @@ public class SubscriptionController {
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public CompletableFuture<Response> createBoostReceiptCredentials(@NotNull @Valid CreateBoostReceiptCredentialsRequest request) {
|
public CompletableFuture<Response> createBoostReceiptCredentials(@NotNull @Valid CreateBoostReceiptCredentialsRequest request) {
|
||||||
return stripeManager.getPaymentIntent(request.paymentIntentId)
|
|
||||||
.thenCompose(paymentIntent -> {
|
final SubscriptionProcessorManager manager = getManagerForProcessor(request.processor);
|
||||||
if (paymentIntent == null) {
|
|
||||||
|
return manager.getPaymentDetails(request.paymentIntentId)
|
||||||
|
.thenCompose(paymentDetails -> {
|
||||||
|
if (paymentDetails == null) {
|
||||||
throw new WebApplicationException(Status.NOT_FOUND);
|
throw new WebApplicationException(Status.NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (StringUtils.equalsIgnoreCase("processing", paymentIntent.getStatus())) {
|
switch (paymentDetails.status()) {
|
||||||
throw new WebApplicationException(Status.NO_CONTENT);
|
case PROCESSING -> throw new WebApplicationException(Status.NO_CONTENT);
|
||||||
}
|
case SUCCEEDED -> {
|
||||||
if (!StringUtils.equalsIgnoreCase("succeeded", paymentIntent.getStatus())) {
|
}
|
||||||
throw new WebApplicationException(Status.PAYMENT_REQUIRED);
|
default -> throw new WebApplicationException(Status.PAYMENT_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
long level = oneTimeDonationConfiguration.boost().level();
|
long level = oneTimeDonationConfiguration.boost().level();
|
||||||
if (paymentIntent.getMetadata() != null) {
|
if (paymentDetails.customMetadata() != null) {
|
||||||
String levelMetadata = paymentIntent.getMetadata()
|
String levelMetadata = paymentDetails.customMetadata()
|
||||||
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
|
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
|
||||||
try {
|
try {
|
||||||
level = Long.parseLong(levelMetadata);
|
level = Long.parseLong(levelMetadata);
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
|
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
|
||||||
paymentIntent.getId(), e);
|
paymentDetails.id(), e);
|
||||||
throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
|
throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -801,9 +920,10 @@ public class SubscriptionController {
|
||||||
throw new BadRequestException("invalid receipt credential request", e);
|
throw new BadRequestException("invalid receipt credential request", e);
|
||||||
}
|
}
|
||||||
final long finalLevel = level;
|
final long finalLevel = level;
|
||||||
return issuedReceiptsManager.recordIssuance(paymentIntent.getId(), receiptCredentialRequest, clock.instant())
|
return issuedReceiptsManager.recordIssuance(paymentDetails.id(), manager.getProcessor(),
|
||||||
|
receiptCredentialRequest, clock.instant())
|
||||||
.thenApply(unused -> {
|
.thenApply(unused -> {
|
||||||
Instant expiration = Instant.ofEpochSecond(paymentIntent.getCreated())
|
Instant expiration = paymentDetails.created()
|
||||||
.plus(levelExpiration)
|
.plus(levelExpiration)
|
||||||
.truncatedTo(ChronoUnit.DAYS)
|
.truncatedTo(ChronoUnit.DAYS)
|
||||||
.plus(1, ChronoUnit.DAYS);
|
.plus(1, ChronoUnit.DAYS);
|
||||||
|
@ -1052,7 +1172,8 @@ public class SubscriptionController {
|
||||||
return stripeManager.getLatestInvoiceForSubscription(record.subscriptionId)
|
return stripeManager.getLatestInvoiceForSubscription(record.subscriptionId)
|
||||||
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, record.subscriptionId))
|
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, record.subscriptionId))
|
||||||
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
|
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
|
||||||
receipt.getInvoiceLineItemId(), receiptCredentialRequest, requestData.now)
|
receipt.getInvoiceLineItemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest,
|
||||||
|
requestData.now)
|
||||||
.thenApply(unused -> receipt))
|
.thenApply(unused -> receipt))
|
||||||
.thenApply(receipt -> {
|
.thenApply(receipt -> {
|
||||||
ReceiptCredentialResponse receiptCredentialResponse;
|
ReceiptCredentialResponse receiptCredentialResponse;
|
||||||
|
|
|
@ -26,14 +26,16 @@ import javax.crypto.spec.SecretKeySpec;
|
||||||
import javax.ws.rs.ClientErrorException;
|
import javax.ws.rs.ClientErrorException;
|
||||||
import javax.ws.rs.core.Response.Status;
|
import javax.ws.rs.core.Response.Status;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
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.DynamoDbAsyncClient;
|
||||||
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||||
|
|
||||||
public class IssuedReceiptsManager {
|
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_ISSUED_RECEIPT_TAG = "B"; // B
|
||||||
public static final String KEY_EXPIRATION = "E"; // N
|
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
|
* 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.
|
* enabling clients to retry in case they missed the original response.
|
||||||
*
|
* <p>
|
||||||
* If this stripe item has already been used to issue another receipt, throws a 409 conflict web application
|
* If this item has already been used to issue another receipt, throws a 409 conflict web application exception.
|
||||||
* exception.
|
* <p>
|
||||||
*
|
* For {@link SubscriptionProcessor#STRIPE}, item is expected to refer to an invoice line item (subscriptions) or a
|
||||||
* Stripe item is expected to refer to an invoice line item (subscriptions) or a payment intent (one-time).
|
* payment intent (one-time).
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Void> recordIssuance(
|
public CompletableFuture<Void> recordIssuance(
|
||||||
String stripeId,
|
String processorItemId,
|
||||||
|
SubscriptionProcessor processor,
|
||||||
ReceiptCredentialRequest request,
|
ReceiptCredentialRequest request,
|
||||||
Instant now) {
|
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()
|
UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()
|
||||||
.tableName(table)
|
.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")
|
.conditionExpression("attribute_not_exists(#key) OR #tag = :tag")
|
||||||
.returnValues(ReturnValue.NONE)
|
.returnValues(ReturnValue.NONE)
|
||||||
.updateExpression("SET "
|
.updateExpression("SET "
|
||||||
+ "#tag = if_not_exists(#tag, :tag), "
|
+ "#tag = if_not_exists(#tag, :tag), "
|
||||||
+ "#exp = if_not_exists(#exp, :exp)")
|
+ "#exp = if_not_exists(#exp, :exp)")
|
||||||
.expressionAttributeNames(Map.of(
|
.expressionAttributeNames(Map.of(
|
||||||
"#key", KEY_STRIPE_ID,
|
"#key", KEY_PROCESSOR_ITEM_ID,
|
||||||
"#tag", KEY_ISSUED_RECEIPT_TAG,
|
"#tag", KEY_ISSUED_RECEIPT_TAG,
|
||||||
"#exp", KEY_EXPIRATION))
|
"#exp", KEY_EXPIRATION))
|
||||||
.expressionAttributeValues(Map.of(
|
.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
|
* A credit card or debit card, including those from Apple Pay and Google Pay
|
||||||
*/
|
*/
|
||||||
CARD,
|
CARD,
|
||||||
|
/**
|
||||||
|
* A PayPal account
|
||||||
|
*/
|
||||||
|
PAYPAL,
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
@ -66,28 +67,18 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
|
|
||||||
private static final String METADATA_KEY_LEVEL = "level";
|
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 String apiKey;
|
||||||
private final Executor executor;
|
private final Executor executor;
|
||||||
private final byte[] idempotencyKeyGenerator;
|
private final byte[] idempotencyKeyGenerator;
|
||||||
private final String boostDescription;
|
private final String boostDescription;
|
||||||
|
private final Set<String> supportedCurrencies;
|
||||||
|
|
||||||
public StripeManager(
|
public StripeManager(
|
||||||
@Nonnull String apiKey,
|
@Nonnull String apiKey,
|
||||||
@Nonnull Executor executor,
|
@Nonnull Executor executor,
|
||||||
@Nonnull byte[] idempotencyKeyGenerator,
|
@Nonnull byte[] idempotencyKeyGenerator,
|
||||||
@Nonnull String boostDescription) {
|
@Nonnull String boostDescription,
|
||||||
|
@Nonnull Set<String> supportedCurrencies) {
|
||||||
this.apiKey = Objects.requireNonNull(apiKey);
|
this.apiKey = Objects.requireNonNull(apiKey);
|
||||||
if (Strings.isNullOrEmpty(apiKey)) {
|
if (Strings.isNullOrEmpty(apiKey)) {
|
||||||
throw new IllegalArgumentException("apiKey cannot be empty");
|
throw new IllegalArgumentException("apiKey cannot be empty");
|
||||||
|
@ -98,6 +89,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty");
|
throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty");
|
||||||
}
|
}
|
||||||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
this.boostDescription = Objects.requireNonNull(boostDescription);
|
||||||
|
this.supportedCurrencies = supportedCurrencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -110,6 +102,11 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
return paymentMethod == PaymentMethod.CARD;
|
return paymentMethod == PaymentMethod.CARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsCurrency(final String currency) {
|
||||||
|
return supportedCurrencies.contains(currency);
|
||||||
|
}
|
||||||
|
|
||||||
private RequestOptions commonOptions() {
|
private RequestOptions commonOptions() {
|
||||||
return commonOptions(null);
|
return commonOptions(null);
|
||||||
}
|
}
|
||||||
|
@ -181,7 +178,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<String> getSupportedCurrencies() {
|
public Set<String> getSupportedCurrencies() {
|
||||||
return SUPPORTED_CURRENCIES;
|
return supportedCurrencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -210,10 +207,15 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, executor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<PaymentIntent> getPaymentIntent(String paymentIntentId) {
|
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
try {
|
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) {
|
} catch (StripeException e) {
|
||||||
if (e.getStatusCode() == 404) {
|
if (e.getStatusCode() == 404) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -224,7 +226,16 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, 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(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||||
.setCustomer(customerId)
|
.setCustomer(customerId)
|
||||||
|
|
|
@ -16,6 +16,7 @@ public enum SubscriptionProcessor {
|
||||||
// because provider IDs are stored, they should not be reused, and great care
|
// because provider IDs are stored, they should not be reused, and great care
|
||||||
// must be used if a provider is removed from the list
|
// must be used if a provider is removed from the list
|
||||||
STRIPE(1),
|
STRIPE(1),
|
||||||
|
BRAINTREE(2),
|
||||||
;
|
;
|
||||||
|
|
||||||
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.subscriptions;
|
package org.whispersystems.textsecuregcm.subscriptions;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@ -14,9 +16,27 @@ public interface SubscriptionProcessorManager {
|
||||||
|
|
||||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||||
|
|
||||||
|
boolean supportsCurrency(String currency);
|
||||||
|
|
||||||
|
Set<String> getSupportedCurrencies();
|
||||||
|
|
||||||
|
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
|
||||||
|
|
||||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
||||||
|
|
||||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
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.dataformat.yaml.YAMLMapper;
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
import com.stripe.exception.ApiException;
|
import com.stripe.exception.ApiException;
|
||||||
import com.stripe.model.Subscription;
|
|
||||||
import com.stripe.model.PaymentIntent;
|
import com.stripe.model.PaymentIntent;
|
||||||
|
import com.stripe.model.Subscription;
|
||||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
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.mappers.CompletionExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
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.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||||
|
@ -87,18 +87,21 @@ class SubscriptionControllerTest {
|
||||||
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
|
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
|
||||||
|
|
||||||
static {
|
static {
|
||||||
|
// this behavior is required by the SubscriptionController constructor
|
||||||
when(STRIPE_MANAGER.getSupportedCurrencies())
|
when(STRIPE_MANAGER.getSupportedCurrencies())
|
||||||
.thenCallRealMethod();
|
.thenReturn(Set.of("usd", "jpy", "bif"));
|
||||||
when(STRIPE_MANAGER.supportsPaymentMethod(PaymentMethod.CARD))
|
when(STRIPE_MANAGER.supportsPaymentMethod(any()))
|
||||||
.thenCallRealMethod();
|
.thenCallRealMethod();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class);
|
||||||
|
|
||||||
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
||||||
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
|
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
|
||||||
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
|
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
|
||||||
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
|
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
|
||||||
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
|
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);
|
ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR);
|
||||||
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
|
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
|
||||||
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
||||||
|
@ -157,8 +160,9 @@ class SubscriptionControllerTest {
|
||||||
@Test
|
@Test
|
||||||
void testCreateBoostPaymentIntent() {
|
void testCreateBoostPaymentIntent() {
|
||||||
when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(300));
|
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));
|
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
|
||||||
|
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
|
||||||
|
|
||||||
String clientSecret = "some_client_secret";
|
String clientSecret = "some_client_secret";
|
||||||
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
|
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
|
||||||
|
@ -168,7 +172,7 @@ class SubscriptionControllerTest {
|
||||||
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
|
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createBoostReceiptInvalid() {
|
void createBoostReceiptInvalid() {
|
||||||
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
|
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.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
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.AttributeDefinition;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||||
|
|
||||||
|
@ -31,9 +32,9 @@ class IssuedReceiptsManagerTest {
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||||
.tableName(ISSUED_RECEIPTS_TABLE_NAME)
|
.tableName(ISSUED_RECEIPTS_TABLE_NAME)
|
||||||
.hashKey(IssuedReceiptsManager.KEY_STRIPE_ID)
|
.hashKey(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
|
||||||
.attributeDefinition(AttributeDefinition.builder()
|
.attributeDefinition(AttributeDefinition.builder()
|
||||||
.attributeName(IssuedReceiptsManager.KEY_STRIPE_ID)
|
.attributeName(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
|
||||||
.attributeType(ScalarAttributeType.S)
|
.attributeType(ScalarAttributeType.S)
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
@ -59,18 +60,21 @@ class IssuedReceiptsManagerTest {
|
||||||
byte[] request1 = new byte[20];
|
byte[] request1 = new byte[20];
|
||||||
SECURE_RANDOM.nextBytes(request1);
|
SECURE_RANDOM.nextBytes(request1);
|
||||||
when(receiptCredentialRequest.serialize()).thenReturn(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));
|
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
|
||||||
|
|
||||||
// same request should succeed
|
// 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));
|
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
|
||||||
|
|
||||||
// same item with new request should fail
|
// same item with new request should fail
|
||||||
byte[] request2 = new byte[20];
|
byte[] request2 = new byte[20];
|
||||||
SECURE_RANDOM.nextBytes(request2);
|
SECURE_RANDOM.nextBytes(request2);
|
||||||
when(receiptCredentialRequest.serialize()).thenReturn(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)).
|
assertThat(future).failsWithin(Duration.ofSeconds(3)).
|
||||||
withThrowableOfType(Throwable.class).
|
withThrowableOfType(Throwable.class).
|
||||||
havingCause().
|
havingCause().
|
||||||
|
@ -80,7 +84,8 @@ class IssuedReceiptsManagerTest {
|
||||||
"status 409"));
|
"status 409"));
|
||||||
|
|
||||||
// different item with new request should be okay though
|
// 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));
|
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