diff --git a/service/config/sample.yml b/service/config/sample.yml index 6e64bf459..ee51a9f8a 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -55,10 +55,12 @@ stripe: idempotencyKeyGenerator: secret://stripe.idempotencyKeyGenerator boostDescription: > Example - supportedCurrencies: - - xts - # - ... - # - Nth supported currency + supportedCurrenciesByPaymentMethod: + CARD: + - usd + - eur + SEPA_DEBIT: + - eur braintree: @@ -70,10 +72,9 @@ braintree: merchantAccounts: # ISO 4217 currency code and its corresponding sub-merchant account 'xts': unset - supportedCurrencies: - - xts - # - ... - # - Nth supported currency + supportedCurrenciesByPaymentMethod: + PAYPAL: + - usd dynamoDbClientConfiguration: region: us-west-2 # AWS Region @@ -367,6 +368,7 @@ subscription: # configuration for Stripe subscriptions BRAINTREE: plan_example # braintree Plan ID oneTimeDonations: + sepaMaxTransactionSizeEuros: '10000' boost: level: 1 expiration: P90D diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index df5721352..e764735b2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -455,12 +455,11 @@ public class WhisperServerService extends Application supportedCurrencies, + @Valid @NotEmpty Map> supportedCurrenciesByPaymentMethod, @NotBlank String graphqlUrl, @NotEmpty Map merchantAccounts, @NotNull diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java index c3516fb36..a128b0270 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java @@ -5,6 +5,7 @@ package org.whispersystems.textsecuregcm.configuration; +import java.math.BigDecimal; import java.time.Duration; import java.util.Map; import javax.validation.Valid; @@ -18,7 +19,8 @@ import javax.validation.constraints.Positive; */ public record OneTimeDonationConfiguration(@Valid ExpiringLevelConfiguration boost, @Valid ExpiringLevelConfiguration gift, - Map currencies) { + Map currencies, + BigDecimal sepaMaxTransactionSizeEuros) { /** * @param badge the numeric donation level ID diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java index 4682b0cde..cef389757 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java @@ -5,15 +5,18 @@ 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; import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes; import org.whispersystems.textsecuregcm.configuration.secrets.SecretString; +import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; public record StripeConfiguration(@NotNull SecretString apiKey, @NotNull SecretBytes idempotencyKeyGenerator, @NotBlank String boostDescription, - @NotEmpty Set<@NotBlank String> supportedCurrencies) { + @Valid @NotEmpty Map> supportedCurrenciesByPaymentMethod) { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index 7e6966c09..5087f2916 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -118,6 +118,7 @@ public class SubscriptionController { private static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued"); private static final String PROCESSOR_TAG_NAME = "processor"; private static final String TYPE_TAG_NAME = "type"; + private static final String EURO_CURRENCY_CODE = "EUR"; public SubscriptionController( @Nonnull Clock clock, @@ -170,8 +171,8 @@ public class SubscriptionController { final List supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) .filter(paymentMethod -> subscriptionProcessorManagers.stream() - .anyMatch(manager -> manager.getSupportedCurrencies().contains(currency) - && manager.supportsPaymentMethod(paymentMethod))) + .anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod) + && manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency))) .map(PaymentMethod::name) .collect(Collectors.toList()); @@ -377,7 +378,7 @@ public class SubscriptionController { private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) { return switch (paymentMethod) { - case CARD -> stripeManager; + case CARD, SEPA_DEBIT -> stripeManager; case PAYPAL -> braintreeManager; }; } @@ -604,6 +605,7 @@ public class SubscriptionController { @Min(1) public long amount; public Long level; + public PaymentMethod paymentMethod = PaymentMethod.CARD; } public static class CreatePayPalBoostRequest extends CreateBoostRequest { @@ -651,15 +653,14 @@ public class SubscriptionController { } /** - * Validates that the currency and amount in the request are supported by the {@code manager} and exceed the minimum - * permitted amount + * Validates that the currency is supported by the {@code manager} and {@code request.paymentMethod} + * and that the amount meets minimum and maximum constraints. * * @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))) { + if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod).contains(request.currency.toLowerCase(Locale.ROOT))) { throw new BadRequestException(Response.status(Status.BAD_REQUEST) .entity(Map.of("error", "unsupported_currency")).build()); } @@ -675,6 +676,16 @@ public class SubscriptionController { "error", "amount_below_currency_minimum", "minimum", minCurrencyAmountMajorUnits.toString())).build()); } + + if (request.paymentMethod == PaymentMethod.SEPA_DEBIT && + amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( + EURO_CURRENCY_CODE, + oneTimeDonationConfiguration.sepaMaxTransactionSizeEuros())) > 0) { + throw new BadRequestException(Response.status(Status.BAD_REQUEST) + .entity(Map.of( + "error", "amount_above_sepa_limit", + "maximum", oneTimeDonationConfiguration.sepaMaxTransactionSizeEuros().toString())).build()); + } } @POST diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index db1eeac80..1d30c487e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -55,13 +55,13 @@ public class BraintreeManager implements SubscriptionProcessorManager { private final BraintreeGateway braintreeGateway; private final BraintreeGraphqlClient braintreeGraphqlClient; private final Executor executor; - private final Set supportedCurrencies; + private final Map> supportedCurrenciesByPaymentMethod; private final Map currenciesToMerchantAccounts; public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey, final String braintreePrivateKey, final String braintreeEnvironment, - final Set supportedCurrencies, + final Map> supportedCurrenciesByPaymentMethod, final Map currenciesToMerchantAccounts, final String graphqlUri, final CircuitBreakerConfiguration circuitBreakerConfiguration, @@ -70,7 +70,7 @@ public class BraintreeManager implements SubscriptionProcessorManager { this(new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey, braintreePrivateKey), - supportedCurrencies, + supportedCurrenciesByPaymentMethod, currenciesToMerchantAccounts, new BraintreeGraphqlClient(FaultTolerantHttpClient.newBuilder() .withName("braintree-graphql") @@ -86,19 +86,20 @@ public class BraintreeManager implements SubscriptionProcessorManager { } @VisibleForTesting - BraintreeManager(final BraintreeGateway braintreeGateway, final Set supportedCurrencies, + BraintreeManager(final BraintreeGateway braintreeGateway, + final Map> supportedCurrenciesByPaymentMethod, final Map currenciesToMerchantAccounts, final BraintreeGraphqlClient braintreeGraphqlClient, final Executor executor) { this.braintreeGateway = braintreeGateway; - this.supportedCurrencies = supportedCurrencies; + this.supportedCurrenciesByPaymentMethod = supportedCurrenciesByPaymentMethod; this.currenciesToMerchantAccounts = currenciesToMerchantAccounts; this.braintreeGraphqlClient = braintreeGraphqlClient; this.executor = executor; } @Override - public Set getSupportedCurrencies() { - return supportedCurrencies; + public Set getSupportedCurrenciesForPaymentMethod(final PaymentMethod paymentMethod) { + return supportedCurrenciesByPaymentMethod.getOrDefault(paymentMethod, Collections.emptySet()); } @Override @@ -111,11 +112,6 @@ public class BraintreeManager implements SubscriptionProcessorManager { return paymentMethod == PaymentMethod.PAYPAL; } - @Override - public boolean supportsCurrency(final String currency) { - return supportedCurrencies.contains(currency.toLowerCase(Locale.ROOT)); - } - @Override public CompletableFuture getPaymentDetails(final String paymentId) { return CompletableFuture.supplyAsync(() -> { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java index e472a8bfe..4738437ea 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java @@ -14,4 +14,8 @@ public enum PaymentMethod { * A PayPal account */ PAYPAL, + /** + * A SEPA debit account + */ + SEPA_DEBIT, } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index 57b4fb933..c1dc9c6b1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -77,14 +77,14 @@ public class StripeManager implements SubscriptionProcessorManager { private final Executor executor; private final byte[] idempotencyKeyGenerator; private final String boostDescription; - private final Set supportedCurrencies; + private final Map> supportedCurrenciesByPaymentMethod; public StripeManager( @Nonnull String apiKey, @Nonnull Executor executor, @Nonnull byte[] idempotencyKeyGenerator, @Nonnull String boostDescription, - @Nonnull Set supportedCurrencies) { + @Nonnull Map> supportedCurrenciesByPaymentMethod) { if (Strings.isNullOrEmpty(apiKey)) { throw new IllegalArgumentException("apiKey cannot be empty"); } @@ -95,7 +95,7 @@ public class StripeManager implements SubscriptionProcessorManager { throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty"); } this.boostDescription = Objects.requireNonNull(boostDescription); - this.supportedCurrencies = supportedCurrencies; + this.supportedCurrenciesByPaymentMethod = supportedCurrenciesByPaymentMethod; } @Override @@ -105,12 +105,7 @@ public class StripeManager implements SubscriptionProcessorManager { @Override public boolean supportsPaymentMethod(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.CARD; - } - - @Override - public boolean supportsCurrency(final String currency) { - return supportedCurrencies.contains(currency); + return paymentMethod == PaymentMethod.CARD || paymentMethod == PaymentMethod.SEPA_DEBIT; } private RequestOptions commonOptions() { @@ -184,8 +179,8 @@ public class StripeManager implements SubscriptionProcessorManager { } @Override - public Set getSupportedCurrencies() { - return supportedCurrencies; + public Set getSupportedCurrenciesForPaymentMethod(final PaymentMethod paymentMethod) { + return supportedCurrenciesByPaymentMethod.getOrDefault(paymentMethod, Collections.emptySet()); } /** diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java index 02bc5d3d7..7be7b2c2d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -19,9 +19,7 @@ public interface SubscriptionProcessorManager { boolean supportsPaymentMethod(PaymentMethod paymentMethod); - boolean supportsCurrency(String currency); - - Set getSupportedCurrencies(); + Set getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod); CompletableFuture getPaymentDetails(String paymentId); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index c7a171041..a3a99a3ba 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -68,6 +68,7 @@ import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; +import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; @@ -98,9 +99,11 @@ class SubscriptionControllerTest { when(manager.supportsPaymentMethod(any())) .thenCallRealMethod(); }); - when(STRIPE_MANAGER.getSupportedCurrencies()) - .thenReturn(Set.of("usd", "jpy", "bif")); - when(BRAINTREE_MANAGER.getSupportedCurrencies()) + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) + .thenReturn(Set.of("usd", "jpy", "bif", "eur")); + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT)) + .thenReturn(Set.of("eur")); + when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL)) .thenReturn(Set.of("usd", "jpy")); } @@ -134,7 +137,8 @@ class SubscriptionControllerTest { @Test void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() { - when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true); + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) + .thenReturn(Set.of("usd", "jpy", "bif", "eur")); final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") .request() .post(Entity.json(""" @@ -146,16 +150,58 @@ class SubscriptionControllerTest { """)); assertThat(response.getStatus()).isEqualTo(400); assertThat(response.hasEntity()).isTrue(); - assertThat(response.readEntity(Map.class)) - .isNotNull() - .containsAllEntriesOf(Map.of( - "error", "amount_below_currency_minimum", - "minimum", "2.50" - )); + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("error")).isEqualTo("amount_below_currency_minimum"); + assertThat(responseMap.get("minimum")).isEqualTo("2.50"); + } + + @Test + void testCreateBoostPaymentIntentAmountAboveSepaLimit() { + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT)) + .thenReturn(Set.of("eur")); + final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") + .request() + .post(Entity.json(""" + { + "currency": "EUR", + "amount": 1000001, + "level": null, + "paymentMethod": "SEPA_DEBIT" + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.hasEntity()).isTrue(); + + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("error")).isEqualTo("amount_above_sepa_limit"); + assertThat(responseMap.get("maximum")).isEqualTo("10000"); + } + + @Test + void testCreateBoostPaymentIntentUnsupportedCurrency() { + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT)) + .thenReturn(Set.of("eur")); + final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") + .request() + .post(Entity.json(""" + { + "currency": "USD", + "amount": 3000, + "level": null, + "paymentMethod": "SEPA_DEBIT" + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.hasEntity()).isTrue(); + + final Map responseMap = response.readEntity(Map.class); + assertThat(responseMap.get("error")).isEqualTo("unsupported_currency"); } @Test void testCreateBoostPaymentIntentLevelAmountMismatch() { + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) + .thenReturn(Set.of("usd", "jpy", "bif", "eur")); final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") .request() .post(Entity.json(""" @@ -171,9 +217,10 @@ class SubscriptionControllerTest { @Test void testCreateBoostPaymentIntent() { + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) + .thenReturn(Set.of("usd", "jpy", "bif", "eur")); 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); @@ -643,7 +690,6 @@ class SubscriptionControllerTest { @Test void getSubscriptionConfiguration() { - when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); @@ -667,7 +713,7 @@ class SubscriptionControllerTest { .request() .get(GetSubscriptionConfigurationResponse.class); - assertThat(response.currencies()).containsKeys("usd", "jpy", "bif").satisfies(currencyMap -> { + assertThat(response.currencies()).containsKeys("usd", "jpy", "bif", "eur").satisfies(currencyMap -> { assertThat(currencyMap).extractingByKey("usd").satisfies(currency -> { assertThat(currency.minimum()).isEqualByComparingTo( BigDecimal.valueOf(2.5).setScale(2, RoundingMode.HALF_EVEN)); @@ -709,6 +755,19 @@ class SubscriptionControllerTest { Map.of("5", BigDecimal.valueOf(5000), "15", BigDecimal.valueOf(15000), "35", BigDecimal.valueOf(35000))); assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD")); }); + + assertThat(currencyMap).extractingByKey("eur").satisfies(currency -> { + assertThat(currency.minimum()).isEqualByComparingTo( + BigDecimal.valueOf(3)); + assertThat(currency.oneTime()).isEqualTo( + Map.of("1", + List.of(BigDecimal.valueOf(5), BigDecimal.valueOf(10), + BigDecimal.valueOf(20), BigDecimal.valueOf(30), BigDecimal.valueOf(50), BigDecimal.valueOf(100)), "100", + List.of(BigDecimal.valueOf(5)))); + assertThat(currency.subscription()).isEqualTo( + Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15),"35", BigDecimal.valueOf(35))); + assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "SEPA_DEBIT")); + }); }); assertThat(response.levels()).containsKeys("1", "5", "15", "35", "100").satisfies(levelsMap -> { @@ -821,6 +880,11 @@ class SubscriptionControllerTest { processorIds: STRIPE: S1 BRAINTREE: O1 + eur: + amount: '5' + processorIds: + STRIPE: A1 + BRAINTREE: B1 15: badge: B2 prices: @@ -839,6 +903,11 @@ class SubscriptionControllerTest { processorIds: STRIPE: S2 BRAINTREE: O2 + eur: + amount: '15' + processorIds: + STRIPE: A2 + BRAINTREE: B2 35: badge: B3 prices: @@ -857,6 +926,11 @@ class SubscriptionControllerTest { processorIds: STRIPE: S3 BRAINTREE: O3 + eur: + amount: '35' + processorIds: + STRIPE: A3 + BRAINTREE: B3 """; private static final String ONETIME_CONFIG_YAML = """ @@ -879,6 +953,16 @@ class SubscriptionControllerTest { - '8' - '9' - '10' + eur: + minimum: '3' + gift: '5' + boosts: + - '5' + - '10' + - '20' + - '30' + - '50' + - '100' jpy: minimum: '250' gift: '2000' @@ -899,6 +983,7 @@ class SubscriptionControllerTest { - '8000' - '9000' - '10000' + sepaMaxTransactionSizeEuros: '10000' """; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java index 924dcee83..fff9204ff 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java @@ -29,7 +29,7 @@ class BraintreeManagerTest { void setup() { braintreeGateway = mock(BraintreeGateway.class); braintreeManager = new BraintreeManager(braintreeGateway, - Set.of("usd"), + Map.of(PaymentMethod.CARD, Set.of("usd")), Map.of("usd", "usdMerchant"), mock(BraintreeGraphqlClient.class), Executors.newSingleThreadExecutor());