Add consolidated subscription configuration API
This commit is contained in:
parent
e883d727fb
commit
397d3cb45a
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
""";
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue