From 397d3cb45a0c1f63deafcc921bd59bf06a9743e6 Mon Sep 17 00:00:00 2001 From: Chris Eager <79161849+eager-signal@users.noreply.github.com> Date: Wed, 16 Nov 2022 12:27:00 -0600 Subject: [PATCH] Add consolidated subscription configuration API --- service/config/sample.yml | 36 +- .../WhisperServerConfiguration.java | 18 +- .../textsecuregcm/WhisperServerService.java | 16 +- .../configuration/BoostConfiguration.java | 58 --- .../configuration/GiftConfiguration.java | 21 -- .../OneTimeDonationConfiguration.java | 31 ++ .../OneTimeDonationCurrencyConfiguration.java | 30 ++ .../SubscriptionLevelConfiguration.java | 9 +- .../controllers/SubscriptionController.java | 205 +++++++++-- .../subscriptions/StripeManager.java | 18 + .../SubscriptionProcessorManager.java | 3 + .../SubscriptionControllerTest.java | 339 ++++++++++++++++-- 12 files changed, 590 insertions(+), 194 deletions(-) delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/BoostConfiguration.java delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/GiftConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java diff --git a/service/config/sample.yml b/service/config/sample.yml index 6c0cb01ce..b75e142e9 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -328,27 +328,27 @@ subscription: # configuration for Stripe subscriptions amount: '10' id: price_example # stripe ID -boost: - level: 1 - expiration: P90D - badge: EXAMPLE +oneTimeDonations: + boost: + level: 1 + expiration: P90D + badge: EXAMPLE + gift: + level: 10 + expiration: P90D + badge: EXAMPLE currencies: # ISO 4217 currency codes and amounts in those currencies xts: - - '1' - - '2' - - '4' - - '8' - - '20' - - '40' - -gift: - level: 10 - expiration: P90D - badge: EXAMPLE - currencies: - # ISO 4217 currency codes and amounts in those currencies - xts: '2' + minimum: '0.5' + gift: '2' + boosts: + - '1' + - '2' + - '4' + - '8' + - '20' + - '40' registrationService: host: registration.example.com diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index b5f3a411c..b76fcea6c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -19,7 +19,6 @@ import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration; import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.configuration.BoostConfiguration; import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; @@ -28,9 +27,9 @@ import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguratio import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; -import org.whispersystems.textsecuregcm.configuration.GiftConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; @@ -231,12 +230,7 @@ public class WhisperServerConfiguration extends Configuration { @Valid @JsonProperty @NotNull - private BoostConfiguration boost; - - @Valid - @JsonProperty - @NotNull - private GiftConfiguration gift; + private OneTimeDonationConfiguration oneTimeDonations; @Valid @NotNull @@ -411,12 +405,8 @@ public class WhisperServerConfiguration extends Configuration { return subscription; } - public BoostConfiguration getBoost() { - return boost; - } - - public GiftConfiguration getGift() { - return gift; + public OneTimeDonationConfiguration getOneTimeDonations() { + return oneTimeDonations; } public ReportMessageConfiguration getReportMessageConfiguration() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index a9d8c5f8d..2aabab069 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -672,19 +672,23 @@ public class WhisperServerService extends Application> currencies; - private final String badge; - - @JsonCreator - public BoostConfiguration( - @JsonProperty("level") long level, - @JsonProperty("expiration") Duration expiration, - @JsonProperty("currencies") Map> currencies, - @JsonProperty("badge") String badge) { - this.level = level; - this.expiration = expiration; - this.currencies = currencies; - this.badge = badge; - } - - public long getLevel() { - return level; - } - - @NotNull - public Duration getExpiration() { - return expiration; - } - - @Valid - @NotNull - public Map<@NotEmpty String, @Valid @ExactlySize(6) List<@DecimalMin("0.01") @NotNull BigDecimal>> getCurrencies() { - return currencies; - } - - @NotEmpty - public String getBadge() { - return badge; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GiftConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GiftConfiguration.java deleted file mode 100644 index 9c7587aa8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GiftConfiguration.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.math.BigDecimal; -import java.time.Duration; -import java.util.Map; -import javax.validation.Valid; -import javax.validation.constraints.DecimalMin; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public record GiftConfiguration( - long level, - @NotNull Duration expiration, - @Valid @NotNull Map<@NotEmpty String, @DecimalMin("0.01") @NotNull BigDecimal> currencies, - @NotEmpty String badge) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java new file mode 100644 index 000000000..c3516fb36 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import java.time.Duration; +import java.util.Map; +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Positive; + +/** + * @param boost configuration for individual donations + * @param gift configuration for gift donations + * @param currencies map of lower-cased ISO 3 currency codes and the suggested donation amounts in that currency + */ +public record OneTimeDonationConfiguration(@Valid ExpiringLevelConfiguration boost, + @Valid ExpiringLevelConfiguration gift, + Map currencies) { + + /** + * @param badge the numeric donation level ID + * @param level the badge ID associated with the level + * @param expiration the duration after which the level expires + */ + public record ExpiringLevelConfiguration(@NotEmpty String badge, @Positive long level, Duration expiration) { + + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java new file mode 100644 index 000000000..6e2d91096 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import java.math.BigDecimal; +import java.util.List; +import javax.validation.Valid; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotNull; +import org.whispersystems.textsecuregcm.util.ExactlySize; + +/** + * One-time donation configuration for a given currency + * + * @param minimum the minimum amount permitted to be charged in this currency + * @param gift the suggested gift donation amount + * @param boosts the list of suggested one-time donation amounts + */ +public record OneTimeDonationCurrencyConfiguration( + @DecimalMin("0.01") BigDecimal minimum, + @DecimalMin("0.01") BigDecimal gift, + @Valid + @ExactlySize(6) + @NotNull + List<@DecimalMin("0.01") BigDecimal> boosts) { + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java index 0f6397984..c410295b8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 Signal Messenger, LLC + * Copyright 2021-2022 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ @@ -15,16 +15,13 @@ import javax.validation.constraints.NotNull; public class SubscriptionLevelConfiguration { private final String badge; - private final String product; private final Map prices; @JsonCreator public SubscriptionLevelConfiguration( @JsonProperty("badge") @NotEmpty String badge, - @JsonProperty("product") @NotEmpty String product, @JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) { this.badge = badge; - this.product = product; this.prices = prices; } @@ -32,10 +29,6 @@ public class SubscriptionLevelConfiguration { return badge; } - public String getProduct() { - return product; - } - public Map getPrices() { return prices; } 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 1d3d45ad5..0c81b8bb1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; import com.stripe.exception.StripeException; import com.stripe.model.Charge; import com.stripe.model.Charge.Outcome; @@ -28,8 +29,10 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.Base64; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -80,8 +83,8 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.badges.BadgeTranslator; import org.whispersystems.textsecuregcm.badges.LevelTranslator; -import org.whispersystems.textsecuregcm.configuration.BoostConfiguration; -import org.whispersystems.textsecuregcm.configuration.GiftConfiguration; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration; @@ -105,22 +108,21 @@ public class SubscriptionController { private final Clock clock; private final SubscriptionConfiguration subscriptionConfiguration; - private final BoostConfiguration boostConfiguration; - private final GiftConfiguration giftConfiguration; + private final OneTimeDonationConfiguration oneTimeDonationConfiguration; private final SubscriptionManager subscriptionManager; private final StripeManager stripeManager; private final ServerZkReceiptOperations zkReceiptOperations; private final IssuedReceiptsManager issuedReceiptsManager; private final BadgeTranslator badgeTranslator; private final LevelTranslator levelTranslator; + private final Map currencyConfiguration; private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(SubscriptionController.class, "invalidAcceptLanguage"); public SubscriptionController( @Nonnull Clock clock, @Nonnull SubscriptionConfiguration subscriptionConfiguration, - @Nonnull BoostConfiguration boostConfiguration, - @Nonnull GiftConfiguration giftConfiguration, + @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration, @Nonnull SubscriptionManager subscriptionManager, @Nonnull StripeManager stripeManager, @Nonnull ServerZkReceiptOperations zkReceiptOperations, @@ -129,14 +131,84 @@ public class SubscriptionController { @Nonnull LevelTranslator levelTranslator) { this.clock = Objects.requireNonNull(clock); this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration); - this.boostConfiguration = Objects.requireNonNull(boostConfiguration); - this.giftConfiguration = Objects.requireNonNull(giftConfiguration); + this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); this.subscriptionManager = Objects.requireNonNull(subscriptionManager); this.stripeManager = Objects.requireNonNull(stripeManager); this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations); this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); this.badgeTranslator = Objects.requireNonNull(badgeTranslator); this.levelTranslator = Objects.requireNonNull(levelTranslator); + + this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration, + this.subscriptionConfiguration, List.of(stripeManager)); + } + + private static Map buildCurrencyConfiguration( + OneTimeDonationConfiguration oneTimeDonationConfiguration, + SubscriptionConfiguration subscriptionConfiguration, + List subscriptionProcessorManagers) { + + return oneTimeDonationConfiguration.currencies() + .entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> { + final String currency = currencyAndConfig.getKey(); + final OneTimeDonationCurrencyConfiguration currencyConfig = currencyAndConfig.getValue(); + + final Map> oneTimeLevelsToSuggestedAmounts = Map.of( + String.valueOf(oneTimeDonationConfiguration.boost().level()), currencyConfig.boosts(), + String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift()) + ); + + final Map subscriptionLevelsToAmounts = subscriptionConfiguration.getLevels() + .entrySet().stream() + .filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency)) + .collect(Collectors.toMap( + levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()), + levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).getAmount())); + + final List supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) + .filter(paymentMethod -> subscriptionProcessorManagers.stream() + .anyMatch(manager -> manager.getSupportedCurrencies().contains(currency) + && manager.supportsPaymentMethod(paymentMethod))) + .map(PaymentMethod::name) + .collect(Collectors.toList()); + + if (supportedPaymentMethods.isEmpty()) { + throw new RuntimeException("Configuration has currency with no processor support: " + currency); + } + + return new CurrencyConfiguration(currencyConfig.minimum(), oneTimeLevelsToSuggestedAmounts, + subscriptionLevelsToAmounts, supportedPaymentMethods); + })); + } + + @VisibleForTesting + GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(List acceptableLanguages) { + + final Map levels = new HashMap<>(); + + subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> { + final LevelConfiguration levelConfiguration = new LevelConfiguration( + levelTranslator.translate(acceptableLanguages, levelConfig.getBadge()), + badgeTranslator.translate(acceptableLanguages, levelConfig.getBadge())); + levels.put(String.valueOf(levelId), levelConfiguration); + }); + + levels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()), + new LevelConfiguration( + levelTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.boost().badge()), + // NB: the one-time badges are PurchasableBadge, which has a `duration` field + new PurchasableBadge( + badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.boost().badge()), + oneTimeDonationConfiguration.boost().expiration()))); + levels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()), + new LevelConfiguration( + levelTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge()), + new PurchasableBadge( + badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge()), + oneTimeDonationConfiguration.gift().expiration()))); + + return new GetSubscriptionConfigurationResponse(currencyConfiguration, levels); } @Timed @@ -463,10 +535,58 @@ public class SubscriptionController { } } + /** + * Comprehensive configuration for subscriptions and one-time donations + * + * @param currencies map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts + * @param levels map of numeric level IDs to level-specific configuration + */ + public record GetSubscriptionConfigurationResponse(Map currencies, + Map levels) { + + } + + /** + * Configuration for a currency - use to present appropriate client interfaces + * + * @param minimum the minimum amount that may be submitted for a one-time donation in the currency + * @param oneTime map of numeric one-time donation level IDs to the list of default amounts to be + * presented + * @param subscription map of numeric subscription level IDs to the amount charged for that level + * @param supportedPaymentMethods the payment methods that support the given currency + */ + public record CurrencyConfiguration(BigDecimal minimum, Map> oneTime, + Map subscription, + List supportedPaymentMethods) { + + } + + /** + * Configuration for a donation level - use to present appropriate client interfaces + * + * @param name the localized name for the level + * @param badge the displayable badge associated with the level + */ + public record LevelConfiguration(String name, Badge badge) { + + } + + @Timed + @GET + @Path("/configuration") + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture getConfiguration(@Context ContainerRequestContext containerRequestContext) { + return CompletableFuture.supplyAsync(() -> { + List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); + return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build(); + }); + } + @Timed @GET @Path("/levels") @Produces(MediaType.APPLICATION_JSON) + @Deprecated // use /configuration public CompletableFuture getLevels(@Context ContainerRequestContext containerRequestContext) { return CompletableFuture.supplyAsync(() -> { List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); @@ -514,16 +634,21 @@ public class SubscriptionController { @GET @Path("/boost/badges") @Produces(MediaType.APPLICATION_JSON) + @Deprecated // use /configuration public CompletableFuture getBoostBadges(@Context ContainerRequestContext containerRequestContext) { return CompletableFuture.supplyAsync(() -> { - long boostLevel = boostConfiguration.getLevel(); - String boostBadge = boostConfiguration.getBadge(); - long giftLevel = giftConfiguration.level(); - String giftBadge = giftConfiguration.badge(); + long boostLevel = oneTimeDonationConfiguration.boost().level(); + String boostBadge = oneTimeDonationConfiguration.boost().badge(); + long giftLevel = oneTimeDonationConfiguration.gift().level(); + String giftBadge = oneTimeDonationConfiguration.gift().badge(); List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); GetBoostBadgesResponse getBoostBadgesResponse = new GetBoostBadgesResponse(Map.of( - boostLevel, new GetBoostBadgesResponse.Level(new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, boostBadge), boostConfiguration.getExpiration())), - giftLevel, new GetBoostBadgesResponse.Level(new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, giftBadge), giftConfiguration.expiration())))); + boostLevel, new GetBoostBadgesResponse.Level( + new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, boostBadge), + oneTimeDonationConfiguration.boost().expiration())), + giftLevel, new GetBoostBadgesResponse.Level( + new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, giftBadge), + oneTimeDonationConfiguration.gift().expiration())))); return Response.ok(getBoostBadgesResponse).build(); }); } @@ -532,20 +657,24 @@ public class SubscriptionController { @GET @Path("/boost/amounts") @Produces(MediaType.APPLICATION_JSON) + @Deprecated // use /configuration public CompletableFuture getBoostAmounts() { return CompletableFuture.supplyAsync(() -> Response.ok( - boostConfiguration.getCurrencies().entrySet().stream().collect( - Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), Entry::getValue))).build()); + oneTimeDonationConfiguration.currencies().entrySet().stream().collect( + Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().boosts()))) + .build()); } @Timed @GET @Path("/boost/amounts/gift") @Produces(MediaType.APPLICATION_JSON) + @Deprecated // use /configuration public CompletableFuture getGiftAmounts() { return CompletableFuture.supplyAsync(() -> Response.ok( - giftConfiguration.currencies().entrySet().stream().collect( - Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), Entry::getValue))).build()); + oneTimeDonationConfiguration.currencies().entrySet().stream().collect( + Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().gift()))) + .build()); } public static class CreateBoostRequest { @@ -576,16 +705,20 @@ public class SubscriptionController { @Produces(MediaType.APPLICATION_JSON) public CompletableFuture createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request) { return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = boostConfiguration.getLevel(); - } - if (request.level == giftConfiguration.level()) { - BigDecimal amountConfigured = giftConfiguration.currencies().get(request.currency.toLowerCase(Locale.ROOT)); - if (amountConfigured == null || stripeManager.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured).compareTo(BigDecimal.valueOf(request.amount)) != 0) { - throw new WebApplicationException(Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); - } - } - }) + if (request.level == null) { + request.level = oneTimeDonationConfiguration.boost().level(); + } + if (request.level == oneTimeDonationConfiguration.gift().level()) { + BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies() + .get(request.currency.toLowerCase(Locale.ROOT)).gift(); + if (amountConfigured == null || + stripeManager.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) + .compareTo(BigDecimal.valueOf(request.amount)) != 0) { + throw new WebApplicationException( + Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); + } + } + }) .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level)) .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); } @@ -627,21 +760,23 @@ public class SubscriptionController { if (!StringUtils.equalsIgnoreCase("succeeded", paymentIntent.getStatus())) { throw new WebApplicationException(Status.PAYMENT_REQUIRED); } - long level = boostConfiguration.getLevel(); + long level = oneTimeDonationConfiguration.boost().level(); if (paymentIntent.getMetadata() != null) { - String levelMetadata = paymentIntent.getMetadata().getOrDefault("level", Long.toString(boostConfiguration.getLevel())); + String levelMetadata = paymentIntent.getMetadata() + .getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level())); try { level = Long.parseLong(levelMetadata); } catch (NumberFormatException e) { - logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, paymentIntent.getId(), e); + logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, + paymentIntent.getId(), e); throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); } } Duration levelExpiration; - if (boostConfiguration.getLevel() == level) { - levelExpiration = boostConfiguration.getExpiration(); - } else if (giftConfiguration.level() == level) { - levelExpiration = giftConfiguration.expiration(); + if (oneTimeDonationConfiguration.boost().level() == level) { + levelExpiration = oneTimeDonationConfiguration.boost().expiration(); + } else if (oneTimeDonationConfiguration.gift().level() == level) { + levelExpiration = oneTimeDonationConfiguration.gift().expiration(); } else { logger.error("level ({}) returned from payment intent that is unknown to the server", level); throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); 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 4e8b5153d..8fc822b27 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -47,6 +47,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; @@ -65,6 +66,18 @@ public class StripeManager implements SubscriptionProcessorManager { private static final String METADATA_KEY_LEVEL = "level"; + // https://stripe.com/docs/currencies?presentment-currency=US + private static final Set SUPPORTED_CURRENCIES = Set.of( + "aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd", + "bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf", + "dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd", + "hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd", + "kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr", + "mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln", + "pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std", + "szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst", + "xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw"); + private final String apiKey; private final Executor executor; private final byte[] idempotencyKeyGenerator; @@ -166,6 +179,11 @@ public class StripeManager implements SubscriptionProcessorManager { .thenApply(SetupIntent::getClientSecret); } + @Override + public Set getSupportedCurrencies() { + return SUPPORTED_CURRENCIES; + } + /** * Creates a payment intent. May throw a 400 WebApplicationException if the amount is too small. */ 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 dff259aae..64ca6c52a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -5,6 +5,7 @@ package org.whispersystems.textsecuregcm.subscriptions; +import java.util.Set; import java.util.concurrent.CompletableFuture; public interface SubscriptionProcessorManager { @@ -16,4 +17,6 @@ public interface SubscriptionProcessorManager { CompletableFuture createCustomer(byte[] subscriberUser); CompletableFuture createPaymentMethodSetupToken(String customerId); + + Set getSupportedCurrencies(); } 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 de7922d24..b361eddaf 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -15,12 +15,15 @@ import static org.mockito.Mockito.when; import static org.whispersystems.textsecuregcm.util.AttributeValues.b; import static org.whispersystems.textsecuregcm.util.AttributeValues.n; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.stripe.exception.ApiException; import com.stripe.model.Subscription; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.Clock; import java.time.Instant; import java.util.Arrays; @@ -32,6 +35,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.function.Predicate; import javax.ws.rs.client.Entity; import javax.ws.rs.core.Response; import org.glassfish.jersey.server.ServerProperties; @@ -46,16 +50,15 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.badges.BadgeTranslator; import org.whispersystems.textsecuregcm.badges.LevelTranslator; -import org.whispersystems.textsecuregcm.configuration.BoostConfiguration; -import org.whispersystems.textsecuregcm.configuration.GiftConfiguration; +import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration; import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetLevelsResponse; +import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; +import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; @@ -67,17 +70,31 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; class SubscriptionControllerTest { private static final Clock CLOCK = mock(Clock.class); - private static final SubscriptionConfiguration SUBSCRIPTION_CONFIG = mock(SubscriptionConfiguration.class); - private static final BoostConfiguration BOOST_CONFIG = mock(BoostConfiguration.class); - private static final GiftConfiguration GIFT_CONFIG = mock(GiftConfiguration.class); + + private static final YAMLMapper YAML_MAPPER = new YAMLMapper(); + + static { + YAML_MAPPER.registerModule(new JavaTimeModule()); + } + + private static final SubscriptionConfiguration SUBSCRIPTION_CONFIG = ConfigHelper.getSubscriptionConfig(); + private static final OneTimeDonationConfiguration ONETIME_CONFIG = ConfigHelper.getOneTimeConfig(); private static final SubscriptionManager SUBSCRIPTION_MANAGER = mock(SubscriptionManager.class); private static final StripeManager STRIPE_MANAGER = mock(StripeManager.class); + + static { + when(STRIPE_MANAGER.getSupportedCurrencies()) + .thenCallRealMethod(); + when(STRIPE_MANAGER.supportsPaymentMethod(PaymentMethod.CARD)) + .thenCallRealMethod(); + } + private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class); private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class); private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController( - CLOCK, SUBSCRIPTION_CONFIG, BOOST_CONFIG, GIFT_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS, + CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR); private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) @@ -96,7 +113,7 @@ class SubscriptionControllerTest { @AfterEach void tearDown() { - reset(CLOCK, SUBSCRIPTION_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, + reset(CLOCK, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR); } @@ -121,7 +138,7 @@ class SubscriptionControllerTest { class SetSubscriptionLevel { private final long levelId = 5L; - private final String currency = "eur"; + private final String currency = "jpy"; private String subscriberId; @@ -145,15 +162,6 @@ class SubscriptionControllerTest { when(SUBSCRIPTION_MANAGER.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any())) .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - final SubscriptionLevelConfiguration levelConfig = mock(SubscriptionLevelConfiguration.class); - when(SUBSCRIPTION_CONFIG.getLevels()) - .thenReturn(Map.of(levelId, levelConfig)); - - final SubscriptionPriceConfiguration priceConfig = new SubscriptionPriceConfiguration("testPriceId", - BigDecimal.TEN); - when(levelConfig.getPrices()) - .thenReturn(Map.of(currency, priceConfig)); - when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong())) .thenReturn(CompletableFuture.completedFuture(null)); } @@ -364,15 +372,170 @@ class SubscriptionControllerTest { } @Test - void getLevels() { - when(SUBSCRIPTION_CONFIG.getLevels()).thenReturn(Map.of( - 1L, new SubscriptionLevelConfiguration("B1", "P1", - Map.of("USD", new SubscriptionPriceConfiguration("R1", BigDecimal.valueOf(100)))), - 2L, new SubscriptionLevelConfiguration("B2", "P2", - Map.of("USD", new SubscriptionPriceConfiguration("R2", BigDecimal.valueOf(200)))), - 3L, new SubscriptionLevelConfiguration("B3", "P3", - Map.of("USD", new SubscriptionPriceConfiguration("R3", BigDecimal.valueOf(300)))) + 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")))); + when(BADGE_TRANSLATOR.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", + List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); + when(BADGE_TRANSLATOR.translate(any(), eq("B3"))).thenReturn(new Badge("B3", "cat3", "name3", "desc3", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", + List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); + when(BADGE_TRANSLATOR.translate(any(), eq("BOOST"))).thenReturn(new Badge("BOOST", "boost1", "boost1", "boost1", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", + List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); + when(BADGE_TRANSLATOR.translate(any(), eq("GIFT"))).thenReturn(new Badge("GIFT", "gift1", "gift1", "gift1", + List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", + List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); + when(LEVEL_TRANSLATOR.translate(any(), eq("B1"))).thenReturn("Z1"); + when(LEVEL_TRANSLATOR.translate(any(), eq("B2"))).thenReturn("Z2"); + when(LEVEL_TRANSLATOR.translate(any(), eq("B3"))).thenReturn("Z3"); + when(LEVEL_TRANSLATOR.translate(any(), eq("BOOST"))).thenReturn("ZBOOST"); + when(LEVEL_TRANSLATOR.translate(any(), eq("GIFT"))).thenReturn("ZGIFT"); + + GetSubscriptionConfigurationResponse response = RESOURCE_EXTENSION.target("/v1/subscription/configuration") + .request() + .get(GetSubscriptionConfigurationResponse.class); + + assertThat(response.currencies()).containsKeys("usd", "jpy", "bif").satisfies(currencyMap -> { + assertThat(currencyMap).extractingByKey("usd").satisfies(currency -> { + assertThat(currency.minimum()).isEqualByComparingTo( + BigDecimal.valueOf(2.5).setScale(2, RoundingMode.HALF_EVEN)); + assertThat(currency.oneTime()).isEqualTo( + Map.of("1", + List.of(BigDecimal.valueOf(5.5).setScale(2, RoundingMode.HALF_EVEN), BigDecimal.valueOf(6), + BigDecimal.valueOf(7), BigDecimal.valueOf(8), + BigDecimal.valueOf(9), BigDecimal.valueOf(10)), "100", + List.of(BigDecimal.valueOf(20)))); + 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")); + }); + + assertThat(currencyMap).extractingByKey("jpy").satisfies(currency -> { + assertThat(currency.minimum()).isEqualByComparingTo( + BigDecimal.valueOf(250)); + assertThat(currency.oneTime()).isEqualTo( + Map.of("1", + List.of(BigDecimal.valueOf(550), BigDecimal.valueOf(600), + BigDecimal.valueOf(700), BigDecimal.valueOf(800), + BigDecimal.valueOf(900), BigDecimal.valueOf(1000)), "100", + List.of(BigDecimal.valueOf(2000)))); + assertThat(currency.subscription()).isEqualTo( + Map.of("5", BigDecimal.valueOf(500), "15", BigDecimal.valueOf(1500), "35", BigDecimal.valueOf(3500))); + assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD")); + }); + + assertThat(currencyMap).extractingByKey("bif").satisfies(currency -> { + assertThat(currency.minimum()).isEqualByComparingTo( + BigDecimal.valueOf(2500)); + assertThat(currency.oneTime()).isEqualTo( + Map.of("1", + List.of(BigDecimal.valueOf(5500), BigDecimal.valueOf(6000), + BigDecimal.valueOf(7000), BigDecimal.valueOf(8000), + BigDecimal.valueOf(9000), BigDecimal.valueOf(10000)), "100", + List.of(BigDecimal.valueOf(20000)))); + assertThat(currency.subscription()).isEqualTo( + Map.of("5", BigDecimal.valueOf(5000), "15", BigDecimal.valueOf(15000), "35", BigDecimal.valueOf(35000))); + assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD")); + }); + }); + + assertThat(response.levels()).containsKeys("1", "5", "15", "35", "100").satisfies(levelsMap -> { + assertThat(levelsMap).extractingByKey("1").satisfies(level -> { + assertThat(level.name()).isEqualTo("ZBOOST"); + assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { + assertThat(badge.getId()).isEqualTo("BOOST"); + assertThat(badge.getName()).isEqualTo("boost1"); + }); + }); + + assertThat(levelsMap).extractingByKey("100").satisfies(level -> { + assertThat(level.name()).isEqualTo("ZGIFT"); + assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { + assertThat(badge.getId()).isEqualTo("GIFT"); + assertThat(badge.getName()).isEqualTo("gift1"); + }); + }); + + assertThat(levelsMap).extractingByKey("5").satisfies(level -> { + assertThat(level.name()).isEqualTo("Z1"); + assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { + assertThat(badge.getId()).isEqualTo("B1"); + assertThat(badge.getName()).isEqualTo("name1"); + }); + }); + + assertThat(levelsMap).extractingByKey("15").satisfies(level -> { + assertThat(level.name()).isEqualTo("Z2"); + assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { + assertThat(badge.getId()).isEqualTo("B2"); + assertThat(badge.getName()).isEqualTo("name2"); + }); + }); + + assertThat(levelsMap).extractingByKey("35").satisfies(level -> { + assertThat(level.name()).isEqualTo("Z3"); + assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { + assertThat(badge.getId()).isEqualTo("B3"); + assertThat(badge.getName()).isEqualTo("name3"); + }); + }); + }); + + // check the badge vs purchasable badge fields + // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration` + Map genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") + .request() + .get(Map.class); + + assertThat(genericResponse.get("levels")).satisfies(levels -> { + final Set oneTimeLevels = Set.of("1", "100"); + oneTimeLevels.forEach(oneTimeLevel -> { + assertThat((Map>>) levels).extractingByKey(oneTimeLevel) + .satisfies(level -> { + assertThat(level.get("badge")).containsKeys("duration"); + }); + }); + + ((Map) levels).keySet().stream() + .filter(Predicate.not(oneTimeLevels::contains)) + .forEach(subscriptionLevel -> { + assertThat((Map>>) levels).extractingByKey(subscriptionLevel) + .satisfies(level -> { + assertThat(level.get("badge")).doesNotContainKeys("duration"); + }); + }); + }); + } + + @Test + void testGetBoostAmounts() { + final Map boostAmounts = RESOURCE_EXTENSION.target("/v1/subscription/boost/amounts") + .request() + .get(Map.class); + + assertThat(boostAmounts).isEqualTo(Map.of( + "USD", List.of(5.50, 6, 7, 8, 9, 10), + "JPY", List.of(550, 600, 700, 800, 900, 1000), + "BIF", List.of(5500, 6000, 7000, 8000, 9000, 10000) )); + + final Map giftAmounts = RESOURCE_EXTENSION.target("/v1/subscription/boost/amounts/gift") + .request() + .get(Map.class); + + assertThat(giftAmounts).isEqualTo(Map.of( + "USD", 20, + "JPY", 2000, + "BIF", 20000 + )); + } + + @Test + void getLevels() { 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")))); @@ -390,28 +553,136 @@ class SubscriptionControllerTest { .request() .get(GetLevelsResponse.class); - assertThat(response.getLevels()).containsKeys(1L, 2L, 3L).satisfies(longLevelMap -> { - assertThat(longLevelMap).extractingByKey(1L).satisfies(level -> { + assertThat(response.getLevels()).containsKeys(5L, 15L, 35L).satisfies(longLevelMap -> { + assertThat(longLevelMap).extractingByKey(5L).satisfies(level -> { assertThat(level.getName()).isEqualTo("Z1"); assertThat(level.getBadge().getId()).isEqualTo("B1"); assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("100"); + assertThat(price).isEqualTo("5"); }); }); - assertThat(longLevelMap).extractingByKey(2L).satisfies(level -> { + assertThat(longLevelMap).extractingByKey(15L).satisfies(level -> { assertThat(level.getName()).isEqualTo("Z2"); assertThat(level.getBadge().getId()).isEqualTo("B2"); assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("200"); + assertThat(price).isEqualTo("15"); }); }); - assertThat(longLevelMap).extractingByKey(3L).satisfies(level -> { + assertThat(longLevelMap).extractingByKey(35L).satisfies(level -> { assertThat(level.getName()).isEqualTo("Z3"); assertThat(level.getBadge().getId()).isEqualTo("B3"); assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("300"); + assertThat(price).isEqualTo("35"); }); }); }); } + + /** + * Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references + */ + private record ConfigHelper() { + + private static SubscriptionConfiguration getSubscriptionConfig() { + return readValue(SUBSCRIPTION_CONFIG_YAML, SubscriptionConfiguration.class); + } + + private static OneTimeDonationConfiguration getOneTimeConfig() { + return readValue(ONETIME_CONFIG_YAML, OneTimeDonationConfiguration.class); + } + + private static T readValue(String yaml, Class type) { + try { + return YAML_MAPPER.readValue(yaml, type); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static final String SUBSCRIPTION_CONFIG_YAML = """ + badgeGracePeriod: P15D + levels: + 5: + badge: B1 + prices: + usd: + amount: '5' + id: R1 + jpy: + amount: '500' + id: Q1 + bif: + amount: '5000' + id: S1 + 15: + badge: B2 + prices: + usd: + amount: '15' + id: R2 + jpy: + amount: '1500' + id: Q2 + bif: + amount: '15000' + id: S2 + 35: + badge: B3 + prices: + usd: + amount: '35' + id: R3 + jpy: + amount: '3500' + id: Q3 + bif: + amount: '35000' + id: S3 + """; + + private static final String ONETIME_CONFIG_YAML = """ + boost: + level: 1 + expiration: P45D + badge: BOOST + gift: + level: 100 + expiration: P60D + badge: GIFT + currencies: + usd: + minimum: '2.50' + gift: '20' + boosts: + - '5.50' + - '6' + - '7' + - '8' + - '9' + - '10' + jpy: + minimum: '250' + gift: '2000' + boosts: + - '550' + - '600' + - '700' + - '800' + - '900' + - '1000' + bif: + minimum: '2500' + gift: '20000' + boosts: + - '5500' + - '6000' + - '7000' + - '8000' + - '9000' + - '10000' + """; + + } + + }