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' amount: '10'
id: price_example # stripe ID id: price_example # stripe ID
boost: oneTimeDonations:
level: 1 boost:
expiration: P90D level: 1
badge: EXAMPLE expiration: P90D
badge: EXAMPLE
gift:
level: 10
expiration: P90D
badge: EXAMPLE
currencies: currencies:
# ISO 4217 currency codes and amounts in those currencies # ISO 4217 currency codes and amounts in those currencies
xts: xts:
- '1' minimum: '0.5'
- '2' gift: '2'
- '4' boosts:
- '8' - '1'
- '20' - '2'
- '40' - '4'
- '8'
gift: - '20'
level: 10 - '40'
expiration: P90D
badge: EXAMPLE
currencies:
# ISO 4217 currency codes and amounts in those currencies
xts: '2'
registrationService: registrationService:
host: registration.example.com 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.AppConfigConfiguration;
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.BoostConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration; import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
@ -28,9 +27,9 @@ import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguratio
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
@ -231,12 +230,7 @@ public class WhisperServerConfiguration extends Configuration {
@Valid @Valid
@JsonProperty @JsonProperty
@NotNull @NotNull
private BoostConfiguration boost; private OneTimeDonationConfiguration oneTimeDonations;
@Valid
@JsonProperty
@NotNull
private GiftConfiguration gift;
@Valid @Valid
@NotNull @NotNull
@ -411,12 +405,8 @@ public class WhisperServerConfiguration extends Configuration {
return subscription; return subscription;
} }
public BoostConfiguration getBoost() { public OneTimeDonationConfiguration getOneTimeDonations() {
return boost; return oneTimeDonations;
}
public GiftConfiguration getGift() {
return gift;
} }
public ReportMessageConfiguration getReportMessageConfiguration() { public ReportMessageConfiguration getReportMessageConfiguration() {

View File

@ -672,19 +672,23 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ReceiptCredentialPresentation::new), ReceiptCredentialPresentation::new),
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor), new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor),
new PaymentsController(currencyManager, paymentsCredentialsGenerator), 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 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 SecureBackupController(backupCredentialsGenerator),
new SecureStorageController(storageCredentialsGenerator), new SecureStorageController(storageCredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
config.getCdnConfiguration().getBucket()) config.getCdnConfiguration().getBucket())
); );
if (config.getSubscription() != null && config.getBoost() != null) { if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getBoost(), commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
config.getGift(), subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager, subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
profileBadgeConverter, resourceBundleLevelTranslator)); resourceBundleLevelTranslator));
} }
for (Object controller : commonControllers) { 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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -15,16 +15,13 @@ import javax.validation.constraints.NotNull;
public class SubscriptionLevelConfiguration { public class SubscriptionLevelConfiguration {
private final String badge; private final String badge;
private final String product;
private final Map<String, SubscriptionPriceConfiguration> prices; private final Map<String, SubscriptionPriceConfiguration> prices;
@JsonCreator @JsonCreator
public SubscriptionLevelConfiguration( public SubscriptionLevelConfiguration(
@JsonProperty("badge") @NotEmpty String badge, @JsonProperty("badge") @NotEmpty String badge,
@JsonProperty("product") @NotEmpty String product,
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) { @JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) {
this.badge = badge; this.badge = badge;
this.product = product;
this.prices = prices; this.prices = prices;
} }
@ -32,10 +29,6 @@ public class SubscriptionLevelConfiguration {
return badge; return badge;
} }
public String getProduct() {
return product;
}
public Map<String, SubscriptionPriceConfiguration> getPrices() { public Map<String, SubscriptionPriceConfiguration> getPrices() {
return prices; 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;
import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import com.stripe.exception.StripeException; import com.stripe.exception.StripeException;
import com.stripe.model.Charge; import com.stripe.model.Charge;
import com.stripe.model.Charge.Outcome; import com.stripe.model.Charge.Outcome;
@ -28,8 +29,10 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@ -80,8 +83,8 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator; import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.badges.LevelTranslator; import org.whispersystems.textsecuregcm.badges.LevelTranslator;
import org.whispersystems.textsecuregcm.configuration.BoostConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration;
@ -105,22 +108,21 @@ public class SubscriptionController {
private final Clock clock; private final Clock clock;
private final SubscriptionConfiguration subscriptionConfiguration; private final SubscriptionConfiguration subscriptionConfiguration;
private final BoostConfiguration boostConfiguration; private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final GiftConfiguration giftConfiguration;
private final SubscriptionManager subscriptionManager; private final SubscriptionManager subscriptionManager;
private final StripeManager stripeManager; private final StripeManager stripeManager;
private final ServerZkReceiptOperations zkReceiptOperations; private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager; private final IssuedReceiptsManager issuedReceiptsManager;
private final BadgeTranslator badgeTranslator; private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator; private final LevelTranslator levelTranslator;
private final Map<String, CurrencyConfiguration> currencyConfiguration;
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(SubscriptionController.class, "invalidAcceptLanguage"); private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(SubscriptionController.class, "invalidAcceptLanguage");
public SubscriptionController( public SubscriptionController(
@Nonnull Clock clock, @Nonnull Clock clock,
@Nonnull SubscriptionConfiguration subscriptionConfiguration, @Nonnull SubscriptionConfiguration subscriptionConfiguration,
@Nonnull BoostConfiguration boostConfiguration, @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
@Nonnull GiftConfiguration giftConfiguration,
@Nonnull SubscriptionManager subscriptionManager, @Nonnull SubscriptionManager subscriptionManager,
@Nonnull StripeManager stripeManager, @Nonnull StripeManager stripeManager,
@Nonnull ServerZkReceiptOperations zkReceiptOperations, @Nonnull ServerZkReceiptOperations zkReceiptOperations,
@ -129,14 +131,84 @@ public class SubscriptionController {
@Nonnull LevelTranslator levelTranslator) { @Nonnull LevelTranslator levelTranslator) {
this.clock = Objects.requireNonNull(clock); this.clock = Objects.requireNonNull(clock);
this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration); this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);
this.boostConfiguration = Objects.requireNonNull(boostConfiguration); this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.giftConfiguration = Objects.requireNonNull(giftConfiguration);
this.subscriptionManager = Objects.requireNonNull(subscriptionManager); this.subscriptionManager = Objects.requireNonNull(subscriptionManager);
this.stripeManager = Objects.requireNonNull(stripeManager); this.stripeManager = Objects.requireNonNull(stripeManager);
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations); this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator); this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator); this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration,
this.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 @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 @Timed
@GET @GET
@Path("/levels") @Path("/levels")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> getLevels(@Context ContainerRequestContext containerRequestContext) { public CompletableFuture<Response> getLevels(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
@ -514,16 +634,21 @@ public class SubscriptionController {
@GET @GET
@Path("/boost/badges") @Path("/boost/badges")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> getBoostBadges(@Context ContainerRequestContext containerRequestContext) { public CompletableFuture<Response> getBoostBadges(@Context ContainerRequestContext containerRequestContext) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
long boostLevel = boostConfiguration.getLevel(); long boostLevel = oneTimeDonationConfiguration.boost().level();
String boostBadge = boostConfiguration.getBadge(); String boostBadge = oneTimeDonationConfiguration.boost().badge();
long giftLevel = giftConfiguration.level(); long giftLevel = oneTimeDonationConfiguration.gift().level();
String giftBadge = giftConfiguration.badge(); String giftBadge = oneTimeDonationConfiguration.gift().badge();
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext);
GetBoostBadgesResponse getBoostBadgesResponse = new GetBoostBadgesResponse(Map.of( GetBoostBadgesResponse getBoostBadgesResponse = new GetBoostBadgesResponse(Map.of(
boostLevel, new GetBoostBadgesResponse.Level(new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, boostBadge), boostConfiguration.getExpiration())), boostLevel, new GetBoostBadgesResponse.Level(
giftLevel, new GetBoostBadgesResponse.Level(new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, giftBadge), giftConfiguration.expiration())))); 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(); return Response.ok(getBoostBadgesResponse).build();
}); });
} }
@ -532,20 +657,24 @@ public class SubscriptionController {
@GET @GET
@Path("/boost/amounts") @Path("/boost/amounts")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> getBoostAmounts() { public CompletableFuture<Response> getBoostAmounts() {
return CompletableFuture.supplyAsync(() -> Response.ok( return CompletableFuture.supplyAsync(() -> Response.ok(
boostConfiguration.getCurrencies().entrySet().stream().collect( oneTimeDonationConfiguration.currencies().entrySet().stream().collect(
Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), Entry::getValue))).build()); Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().boosts())))
.build());
} }
@Timed @Timed
@GET @GET
@Path("/boost/amounts/gift") @Path("/boost/amounts/gift")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Deprecated // use /configuration
public CompletableFuture<Response> getGiftAmounts() { public CompletableFuture<Response> getGiftAmounts() {
return CompletableFuture.supplyAsync(() -> Response.ok( return CompletableFuture.supplyAsync(() -> Response.ok(
giftConfiguration.currencies().entrySet().stream().collect( oneTimeDonationConfiguration.currencies().entrySet().stream().collect(
Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), Entry::getValue))).build()); Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().gift())))
.build());
} }
public static class CreateBoostRequest { public static class CreateBoostRequest {
@ -576,16 +705,20 @@ public class SubscriptionController {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request) { public CompletableFuture<Response> createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request) {
return CompletableFuture.runAsync(() -> { return CompletableFuture.runAsync(() -> {
if (request.level == null) { if (request.level == null) {
request.level = boostConfiguration.getLevel(); request.level = oneTimeDonationConfiguration.boost().level();
} }
if (request.level == giftConfiguration.level()) { if (request.level == oneTimeDonationConfiguration.gift().level()) {
BigDecimal amountConfigured = giftConfiguration.currencies().get(request.currency.toLowerCase(Locale.ROOT)); BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies()
if (amountConfigured == null || stripeManager.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured).compareTo(BigDecimal.valueOf(request.amount)) != 0) { .get(request.currency.toLowerCase(Locale.ROOT)).gift();
throw new WebApplicationException(Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); 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)) .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level))
.thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build());
} }
@ -627,21 +760,23 @@ public class SubscriptionController {
if (!StringUtils.equalsIgnoreCase("succeeded", paymentIntent.getStatus())) { if (!StringUtils.equalsIgnoreCase("succeeded", paymentIntent.getStatus())) {
throw new WebApplicationException(Status.PAYMENT_REQUIRED); throw new WebApplicationException(Status.PAYMENT_REQUIRED);
} }
long level = boostConfiguration.getLevel(); long level = oneTimeDonationConfiguration.boost().level();
if (paymentIntent.getMetadata() != null) { 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 { try {
level = Long.parseLong(levelMetadata); level = Long.parseLong(levelMetadata);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, paymentIntent.getId(), e); logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentIntent.getId(), e);
throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
} }
} }
Duration levelExpiration; Duration levelExpiration;
if (boostConfiguration.getLevel() == level) { if (oneTimeDonationConfiguration.boost().level() == level) {
levelExpiration = boostConfiguration.getExpiration(); levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (giftConfiguration.level() == level) { } else if (oneTimeDonationConfiguration.gift().level() == level) {
levelExpiration = giftConfiguration.expiration(); levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else { } else {
logger.error("level ({}) returned from payment intent that is unknown to the server", level); logger.error("level ({}) returned from payment intent that is unknown to the server", level);
throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);

View File

@ -47,6 +47,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -65,6 +66,18 @@ public class StripeManager implements SubscriptionProcessorManager {
private static final String METADATA_KEY_LEVEL = "level"; private static final String METADATA_KEY_LEVEL = "level";
// https://stripe.com/docs/currencies?presentment-currency=US
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
"aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd",
"bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf",
"dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd",
"hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd",
"kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr",
"mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln",
"pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std",
"szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst",
"xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw");
private final String apiKey; private final String apiKey;
private final Executor executor; private final Executor executor;
private final byte[] idempotencyKeyGenerator; private final byte[] idempotencyKeyGenerator;
@ -166,6 +179,11 @@ public class StripeManager implements SubscriptionProcessorManager {
.thenApply(SetupIntent::getClientSecret); .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. * 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; package org.whispersystems.textsecuregcm.subscriptions;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
public interface SubscriptionProcessorManager { public interface SubscriptionProcessorManager {
@ -16,4 +17,6 @@ public interface SubscriptionProcessorManager {
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser); CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId); 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.b;
import static org.whispersystems.textsecuregcm.util.AttributeValues.n; 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.exception.ApiException;
import com.stripe.model.Subscription; import com.stripe.model.Subscription;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension; import io.dropwizard.testing.junit5.ResourceExtension;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
@ -32,6 +35,7 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.function.Predicate;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.glassfish.jersey.server.ServerProperties; 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.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator; import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.badges.LevelTranslator; import org.whispersystems.textsecuregcm.badges.LevelTranslator;
import org.whispersystems.textsecuregcm.configuration.BoostConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; 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.GetLevelsResponse;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse;
import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
@ -67,17 +70,31 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
class SubscriptionControllerTest { class SubscriptionControllerTest {
private static final Clock CLOCK = mock(Clock.class); 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 YAMLMapper YAML_MAPPER = new YAMLMapper();
private static final GiftConfiguration GIFT_CONFIG = mock(GiftConfiguration.class);
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 SubscriptionManager SUBSCRIPTION_MANAGER = mock(SubscriptionManager.class);
private static final StripeManager STRIPE_MANAGER = mock(StripeManager.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 ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class); private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController( private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
CLOCK, SUBSCRIPTION_CONFIG, 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); ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
@ -96,7 +113,7 @@ class SubscriptionControllerTest {
@AfterEach @AfterEach
void tearDown() { 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); BADGE_TRANSLATOR, LEVEL_TRANSLATOR);
} }
@ -121,7 +138,7 @@ class SubscriptionControllerTest {
class SetSubscriptionLevel { class SetSubscriptionLevel {
private final long levelId = 5L; private final long levelId = 5L;
private final String currency = "eur"; private final String currency = "jpy";
private String subscriberId; private String subscriberId;
@ -145,15 +162,6 @@ class SubscriptionControllerTest {
when(SUBSCRIPTION_MANAGER.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any())) when(SUBSCRIPTION_MANAGER.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any()))
.thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); .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())) when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));
} }
@ -364,15 +372,170 @@ class SubscriptionControllerTest {
} }
@Test @Test
void getLevels() { void getSubscriptionConfiguration() {
when(SUBSCRIPTION_CONFIG.getLevels()).thenReturn(Map.of(
1L, new SubscriptionLevelConfiguration("B1", "P1", when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1",
Map.of("USD", new SubscriptionPriceConfiguration("R1", BigDecimal.valueOf(100)))), List.of("l", "m", "h", "x", "xx", "xxx"), "SVG",
2L, new SubscriptionLevelConfiguration("B2", "P2", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
Map.of("USD", new SubscriptionPriceConfiguration("R2", BigDecimal.valueOf(200)))), when(BADGE_TRANSLATOR.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2",
3L, new SubscriptionLevelConfiguration("B3", "P3", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG",
Map.of("USD", new SubscriptionPriceConfiguration("R3", BigDecimal.valueOf(300)))) 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", 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("l", "m", "h", "x", "xx", "xxx"), "SVG",
List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
@ -390,28 +553,136 @@ class SubscriptionControllerTest {
.request() .request()
.get(GetLevelsResponse.class); .get(GetLevelsResponse.class);
assertThat(response.getLevels()).containsKeys(1L, 2L, 3L).satisfies(longLevelMap -> { assertThat(response.getLevels()).containsKeys(5L, 15L, 35L).satisfies(longLevelMap -> {
assertThat(longLevelMap).extractingByKey(1L).satisfies(level -> { assertThat(longLevelMap).extractingByKey(5L).satisfies(level -> {
assertThat(level.getName()).isEqualTo("Z1"); assertThat(level.getName()).isEqualTo("Z1");
assertThat(level.getBadge().getId()).isEqualTo("B1"); assertThat(level.getBadge().getId()).isEqualTo("B1");
assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { 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.getName()).isEqualTo("Z2");
assertThat(level.getBadge().getId()).isEqualTo("B2"); assertThat(level.getBadge().getId()).isEqualTo("B2");
assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { 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.getName()).isEqualTo("Z3");
assertThat(level.getBadge().getId()).isEqualTo("B3"); assertThat(level.getBadge().getId()).isEqualTo("B3");
assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { 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'
""";
}
} }