Add consolidated subscription configuration API

This commit is contained in:
Chris Eager 2022-11-16 12:27:00 -06:00 committed by GitHub
parent e883d727fb
commit 397d3cb45a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 590 additions and 194 deletions

View File

@ -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

View File

@ -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() {

View File

@ -672,19 +672,23 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ReceiptCredentialPresentation::new),
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor),
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RemoteConfigController(remoteConfigsManager, adminEventLogger, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
config.getRemoteConfigConfiguration().getAuthorizedTokens(),
config.getRemoteConfigConfiguration().getGlobalConfig()),
new SecureBackupController(backupCredentialsGenerator),
new SecureStorageController(storageCredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
config.getCdnConfiguration().getBucket())
);
if (config.getSubscription() != null && config.getBoost() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getBoost(),
config.getGift(), subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager,
profileBadgeConverter, resourceBundleLevelTranslator));
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
resourceBundleLevelTranslator));
}
for (Object controller : commonControllers) {

View File

@ -1,58 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import javax.validation.Valid;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.util.ExactlySize;
public class BoostConfiguration {
private final long level;
private final Duration expiration;
private final Map<String, List<BigDecimal>> currencies;
private final String badge;
@JsonCreator
public BoostConfiguration(
@JsonProperty("level") long level,
@JsonProperty("expiration") Duration expiration,
@JsonProperty("currencies") Map<String, List<BigDecimal>> 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;
}
}

View File

@ -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) {
}

View File

@ -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<String, @Valid OneTimeDonationCurrencyConfiguration> 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) {
}
}

View File

@ -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) {
}

View File

@ -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<String, SubscriptionPriceConfiguration> 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<String, SubscriptionPriceConfiguration> getPrices() {
return prices;
}

View File

@ -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<String, CurrencyConfiguration> 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<String, CurrencyConfiguration> buildCurrencyConfiguration(
OneTimeDonationConfiguration oneTimeDonationConfiguration,
SubscriptionConfiguration subscriptionConfiguration,
List<SubscriptionProcessorManager> subscriptionProcessorManagers) {
return oneTimeDonationConfiguration.currencies()
.entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
final String currency = currencyAndConfig.getKey();
final OneTimeDonationCurrencyConfiguration currencyConfig = currencyAndConfig.getValue();
final Map<String, List<BigDecimal>> oneTimeLevelsToSuggestedAmounts = Map.of(
String.valueOf(oneTimeDonationConfiguration.boost().level()), currencyConfig.boosts(),
String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift())
);
final Map<String, BigDecimal> 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<String> 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<Locale> acceptableLanguages) {
final Map<String, LevelConfiguration> 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<String, CurrencyConfiguration> currencies,
Map<String, LevelConfiguration> 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<String, List<BigDecimal>> oneTime,
Map<String, BigDecimal> subscription,
List<String> 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<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build();
});
}
@Timed
@GET
@Path("/levels")
@Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> getLevels(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
@ -514,16 +634,21 @@ public class SubscriptionController {
@GET
@Path("/boost/badges")
@Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> 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<Locale> 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<Response> 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<Response> 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<Response> 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);

View File

@ -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<String> SUPPORTED_CURRENCIES = Set.of(
"aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd",
"bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf",
"dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd",
"hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd",
"kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr",
"mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln",
"pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std",
"szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst",
"xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw");
private final String apiKey;
private final Executor executor;
private final byte[] idempotencyKeyGenerator;
@ -166,6 +179,11 @@ public class StripeManager implements SubscriptionProcessorManager {
.thenApply(SetupIntent::getClientSecret);
}
@Override
public Set<String> getSupportedCurrencies() {
return SUPPORTED_CURRENCIES;
}
/**
* Creates a payment intent. May throw a 400 WebApplicationException if the amount is too small.
*/

View File

@ -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<ProcessorCustomer> createCustomer(byte[] subscriberUser);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
Set<String> getSupportedCurrencies();
}

View File

@ -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<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
.request()
.get(Map.class);
assertThat(genericResponse.get("levels")).satisfies(levels -> {
final Set<String> oneTimeLevels = Set.of("1", "100");
oneTimeLevels.forEach(oneTimeLevel -> {
assertThat((Map<String, Map<String, Map<String, Object>>>) levels).extractingByKey(oneTimeLevel)
.satisfies(level -> {
assertThat(level.get("badge")).containsKeys("duration");
});
});
((Map<String, ?>) levels).keySet().stream()
.filter(Predicate.not(oneTimeLevels::contains))
.forEach(subscriptionLevel -> {
assertThat((Map<String, Map<String, Map<String, Object>>>) 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> T readValue(String yaml, Class<T> 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'
""";
}
}