Add backup levels to subscription configuration response
This commit is contained in:
parent
44ad9d4f5f
commit
4863e1d227
|
@ -8,11 +8,15 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.google.common.collect.Sets;
|
||||||
import io.dropwizard.validation.ValidationMethod;
|
import io.dropwizard.validation.ValidationMethod;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.Min;
|
import javax.validation.constraints.Min;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
@ -21,16 +25,23 @@ public class SubscriptionConfiguration {
|
||||||
|
|
||||||
private final Duration badgeGracePeriod;
|
private final Duration badgeGracePeriod;
|
||||||
private final Duration badgeExpiration;
|
private final Duration badgeExpiration;
|
||||||
private final Map<Long, SubscriptionLevelConfiguration> levels;
|
|
||||||
|
private final Duration backupExpiration;
|
||||||
|
private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
|
||||||
|
private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;
|
||||||
|
|
||||||
@JsonCreator
|
@JsonCreator
|
||||||
public SubscriptionConfiguration(
|
public SubscriptionConfiguration(
|
||||||
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
|
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
|
||||||
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
|
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
|
||||||
@JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, @NotNull @Valid SubscriptionLevelConfiguration> levels) {
|
@JsonProperty("backupExpiration") @Valid Duration backupExpiration,
|
||||||
|
@JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels,
|
||||||
|
@JsonProperty("backupLevels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) {
|
||||||
this.badgeGracePeriod = badgeGracePeriod;
|
this.badgeGracePeriod = badgeGracePeriod;
|
||||||
this.badgeExpiration = badgeExpiration;
|
this.badgeExpiration = badgeExpiration;
|
||||||
this.levels = levels;
|
this.donationLevels = donationLevels;
|
||||||
|
this.backupExpiration = backupExpiration;
|
||||||
|
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Duration getBadgeGracePeriod() {
|
public Duration getBadgeGracePeriod() {
|
||||||
|
@ -42,19 +53,43 @@ public class SubscriptionConfiguration {
|
||||||
return badgeExpiration;
|
return badgeExpiration;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<Long, SubscriptionLevelConfiguration> getLevels() {
|
public Duration getBackupExpiration() {
|
||||||
return levels;
|
return backupExpiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionLevelConfiguration getSubscriptionLevel(long level) {
|
||||||
|
return Optional
|
||||||
|
.<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))
|
||||||
|
.orElse(this.backupLevels.get(level));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Long, SubscriptionLevelConfiguration.Donation> getDonationLevels() {
|
||||||
|
return donationLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Long, SubscriptionLevelConfiguration.Backup> getBackupLevels() {
|
||||||
|
return backupLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
@ValidationMethod(message = "Backup levels and donation levels should not contain the same level identifier")
|
||||||
|
public boolean areLevelsNonOverlapping() {
|
||||||
|
return Sets.intersection(backupLevels.keySet(), donationLevels.keySet()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@ValidationMethod(message = "has a mismatch between the levels supported currencies")
|
@ValidationMethod(message = "has a mismatch between the levels supported currencies")
|
||||||
public boolean isCurrencyListSameAcrossAllLevels() {
|
public boolean isCurrencyListSameAcrossAllLevels() {
|
||||||
Optional<SubscriptionLevelConfiguration> any = levels.values().stream().findAny();
|
final Map<Long, SubscriptionLevelConfiguration> subscriptionLevels = Stream
|
||||||
|
.concat(donationLevels.entrySet().stream(), backupLevels.entrySet().stream())
|
||||||
|
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||||
|
|
||||||
|
Optional<SubscriptionLevelConfiguration> any = subscriptionLevels.values().stream().findAny();
|
||||||
if (any.isEmpty()) {
|
if (any.isEmpty()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> currencies = any.get().getPrices().keySet();
|
Set<String> currencies = any.get().prices().keySet();
|
||||||
return levels.values().stream().allMatch(level -> currencies.equals(level.getPrices().keySet()));
|
return subscriptionLevels.values().stream().allMatch(level -> currencies.equals(level.prices().keySet()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,31 +5,35 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
public class SubscriptionLevelConfiguration {
|
public sealed interface SubscriptionLevelConfiguration permits
|
||||||
|
SubscriptionLevelConfiguration.Backup, SubscriptionLevelConfiguration.Donation {
|
||||||
|
|
||||||
private final String badge;
|
Map<String, SubscriptionPriceConfiguration> prices();
|
||||||
private final Map<String, SubscriptionPriceConfiguration> prices;
|
|
||||||
|
|
||||||
@JsonCreator
|
enum Type {
|
||||||
public SubscriptionLevelConfiguration(
|
DONATION,
|
||||||
|
BACKUP
|
||||||
|
}
|
||||||
|
|
||||||
|
default Type type() {
|
||||||
|
return switch (this) {
|
||||||
|
case Backup b -> Type.BACKUP;
|
||||||
|
case Donation d -> Type.DONATION;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
record Backup(
|
||||||
|
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices)
|
||||||
|
implements SubscriptionLevelConfiguration {}
|
||||||
|
|
||||||
|
record Donation(
|
||||||
@JsonProperty("badge") @NotEmpty String badge,
|
@JsonProperty("badge") @NotEmpty String badge,
|
||||||
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) {
|
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices)
|
||||||
this.badge = badge;
|
implements SubscriptionLevelConfiguration {}
|
||||||
this.prices = prices;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getBadge() {
|
|
||||||
return badge;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, SubscriptionPriceConfiguration> getPrices() {
|
|
||||||
return prices;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
|
@ -123,6 +124,7 @@ public class SubscriptionController {
|
||||||
private static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued");
|
private static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued");
|
||||||
private static final String PROCESSOR_TAG_NAME = "processor";
|
private static final String PROCESSOR_TAG_NAME = "processor";
|
||||||
private static final String TYPE_TAG_NAME = "type";
|
private static final String TYPE_TAG_NAME = "type";
|
||||||
|
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
|
||||||
private static final String EURO_CURRENCY_CODE = "EUR";
|
private static final String EURO_CURRENCY_CODE = "EUR";
|
||||||
private static final Semver LAST_PROBLEMATIC_IOS_VERSION = new Semver("6.44.0");
|
private static final Semver LAST_PROBLEMATIC_IOS_VERSION = new Semver("6.44.0");
|
||||||
|
|
||||||
|
@ -166,12 +168,12 @@ public class SubscriptionController {
|
||||||
String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift())
|
String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift())
|
||||||
);
|
);
|
||||||
|
|
||||||
final Map<String, BigDecimal> subscriptionLevelsToAmounts = subscriptionConfiguration.getLevels()
|
final Function<Map<Long, ? extends SubscriptionLevelConfiguration>, Map<String, BigDecimal>> extractSubscriptionAmounts = levels ->
|
||||||
.entrySet().stream()
|
levels.entrySet().stream()
|
||||||
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency))
|
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().prices().containsKey(currency))
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
|
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
|
||||||
levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount()));
|
levelIdAndConfig -> levelIdAndConfig.getValue().prices().get(currency).amount()));
|
||||||
|
|
||||||
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
|
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
|
||||||
.filter(paymentMethod -> subscriptionProcessorManagers.stream()
|
.filter(paymentMethod -> subscriptionProcessorManagers.stream()
|
||||||
|
@ -184,19 +186,24 @@ public class SubscriptionController {
|
||||||
throw new RuntimeException("Configuration has currency with no processor support: " + currency);
|
throw new RuntimeException("Configuration has currency with no processor support: " + currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CurrencyConfiguration(currencyConfig.minimum(), oneTimeLevelsToSuggestedAmounts,
|
return new CurrencyConfiguration(
|
||||||
subscriptionLevelsToAmounts, supportedPaymentMethods);
|
currencyConfig.minimum(),
|
||||||
|
oneTimeLevelsToSuggestedAmounts,
|
||||||
|
extractSubscriptionAmounts.apply(subscriptionConfiguration.getDonationLevels()),
|
||||||
|
extractSubscriptionAmounts.apply(subscriptionConfiguration.getBackupLevels()),
|
||||||
|
supportedPaymentMethods);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(final List<Locale> acceptableLanguages) {
|
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(
|
||||||
|
final List<Locale> acceptableLanguages) {
|
||||||
final Map<String, LevelConfiguration> levels = new HashMap<>();
|
final Map<String, LevelConfiguration> levels = new HashMap<>();
|
||||||
|
|
||||||
subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> {
|
subscriptionConfiguration.getDonationLevels().forEach((levelId, levelConfig) -> {
|
||||||
final LevelConfiguration levelConfiguration = new LevelConfiguration(
|
final LevelConfiguration levelConfiguration = new LevelConfiguration(
|
||||||
levelTranslator.translate(acceptableLanguages, levelConfig.getBadge()),
|
levelTranslator.translate(acceptableLanguages, levelConfig.badge()),
|
||||||
badgeTranslator.translate(acceptableLanguages, levelConfig.getBadge()));
|
badgeTranslator.translate(acceptableLanguages, levelConfig.badge()));
|
||||||
levels.put(String.valueOf(levelId), levelConfiguration);
|
levels.put(String.valueOf(levelId), levelConfiguration);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -478,6 +485,13 @@ public class SubscriptionController {
|
||||||
currency.toLowerCase(Locale.ROOT)))) {
|
currency.toLowerCase(Locale.ROOT)))) {
|
||||||
return CompletableFuture.completedFuture(subscription);
|
return CompletableFuture.completedFuture(subscription);
|
||||||
}
|
}
|
||||||
|
if (!subscriptionsAreSameType(existingLevelAndCurrency.level(), level)) {
|
||||||
|
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
|
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
return manager.updateSubscription(
|
return manager.updateSubscription(
|
||||||
subscription, subscriptionTemplateId, level, idempotencyKey)
|
subscription, subscriptionTemplateId, level, idempotencyKey)
|
||||||
.thenCompose(updatedSubscription ->
|
.thenCompose(updatedSubscription ->
|
||||||
|
@ -519,6 +533,11 @@ public class SubscriptionController {
|
||||||
.thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build());
|
.thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean subscriptionsAreSameType(long level1, long level2) {
|
||||||
|
return subscriptionConfiguration.getSubscriptionLevel(level1).type()
|
||||||
|
== subscriptionConfiguration.getSubscriptionLevel(level2).type();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprehensive configuration for subscriptions and one-time donations
|
* Comprehensive configuration for subscriptions and one-time donations
|
||||||
*
|
*
|
||||||
|
@ -538,10 +557,12 @@ public class SubscriptionController {
|
||||||
* @param oneTime map of numeric one-time donation level IDs to the list of default amounts to be
|
* @param oneTime map of numeric one-time donation level IDs to the list of default amounts to be
|
||||||
* presented
|
* presented
|
||||||
* @param subscription map of numeric subscription level IDs to the amount charged for that level
|
* @param subscription map of numeric subscription level IDs to the amount charged for that level
|
||||||
|
* @param backupSubscription map of numeric backup level IDs to the amount charged for that level
|
||||||
* @param supportedPaymentMethods the payment methods that support the given currency
|
* @param supportedPaymentMethods the payment methods that support the given currency
|
||||||
*/
|
*/
|
||||||
public record CurrencyConfiguration(BigDecimal minimum, Map<String, List<BigDecimal>> oneTime,
|
public record CurrencyConfiguration(BigDecimal minimum, Map<String, List<BigDecimal>> oneTime,
|
||||||
Map<String, BigDecimal> subscription,
|
Map<String, BigDecimal> subscription,
|
||||||
|
Map<String, BigDecimal> backupSubscription,
|
||||||
List<String> supportedPaymentMethods) {
|
List<String> supportedPaymentMethods) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -946,7 +967,8 @@ public class SubscriptionController {
|
||||||
try {
|
try {
|
||||||
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
||||||
receiptCredentialRequest,
|
receiptCredentialRequest,
|
||||||
receiptExpirationWithGracePeriod(receipt.paidAt()).getEpochSecond(), receipt.level());
|
receiptExpirationWithGracePeriod(receipt.paidAt(), receipt.level()).getEpochSecond(),
|
||||||
|
receipt.level());
|
||||||
} catch (VerificationFailedException e) {
|
} catch (VerificationFailedException e) {
|
||||||
throw new BadRequestException("receipt credential request failed verification", e);
|
throw new BadRequestException("receipt credential request failed verification", e);
|
||||||
}
|
}
|
||||||
|
@ -954,6 +976,9 @@ public class SubscriptionController {
|
||||||
Tags.of(
|
Tags.of(
|
||||||
Tag.of(PROCESSOR_TAG_NAME, manager.getProcessor().toString()),
|
Tag.of(PROCESSOR_TAG_NAME, manager.getProcessor().toString()),
|
||||||
Tag.of(TYPE_TAG_NAME, "subscription"),
|
Tag.of(TYPE_TAG_NAME, "subscription"),
|
||||||
|
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
|
||||||
|
subscriptionConfiguration.getSubscriptionLevel(receipt.level()).type().name()
|
||||||
|
.toLowerCase(Locale.ROOT)),
|
||||||
UserAgentTagUtil.getPlatformTag(userAgent)))
|
UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||||
.increment();
|
.increment();
|
||||||
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize()))
|
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize()))
|
||||||
|
@ -989,32 +1014,38 @@ public class SubscriptionController {
|
||||||
new ClientErrorException(Status.CONFLICT)))
|
new ClientErrorException(Status.CONFLICT)))
|
||||||
.thenApply(customer -> Response.ok().build());
|
.thenApply(customer -> Response.ok().build());
|
||||||
}
|
}
|
||||||
private Instant receiptExpirationWithGracePeriod(Instant paidAt) {
|
|
||||||
return paidAt.plus(subscriptionConfiguration.getBadgeExpiration())
|
|
||||||
.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
|
||||||
.truncatedTo(ChronoUnit.DAYS)
|
|
||||||
.plus(1, ChronoUnit.DAYS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) {
|
private Instant receiptExpirationWithGracePeriod(Instant paidAt, long level) {
|
||||||
SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level);
|
return switch (subscriptionConfiguration.getSubscriptionLevel(level).type()) {
|
||||||
if (levelConfiguration == null) {
|
case DONATION -> paidAt.plus(subscriptionConfiguration.getBadgeExpiration())
|
||||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
||||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
.truncatedTo(ChronoUnit.DAYS)
|
||||||
new SetSubscriptionLevelErrorResponse.Error(
|
.plus(1, ChronoUnit.DAYS);
|
||||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
case BACKUP -> paidAt.plus(subscriptionConfiguration.getBackupExpiration())
|
||||||
.build());
|
.truncatedTo(ChronoUnit.DAYS)
|
||||||
}
|
.plus(1, ChronoUnit.DAYS);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return Optional.ofNullable(levelConfiguration.getPrices()
|
|
||||||
.get(currency.toLowerCase(Locale.ROOT)))
|
private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) {
|
||||||
.map(priceConfiguration -> priceConfiguration.processorIds().get(processor))
|
final SubscriptionLevelConfiguration config = subscriptionConfiguration.getSubscriptionLevel(level);
|
||||||
.orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST)
|
if (config == null) {
|
||||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
new SetSubscriptionLevelErrorResponse.Error(
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
.build()));
|
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
||||||
|
.build());
|
||||||
}
|
}
|
||||||
|
final Optional<String> templateId = Optional
|
||||||
|
.ofNullable(config.prices().get(currency.toLowerCase(Locale.ROOT)))
|
||||||
|
.map(priceConfiguration -> priceConfiguration.processorIds().get(processor));
|
||||||
|
return templateId.orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
|
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
|
||||||
|
.build()));
|
||||||
|
}
|
||||||
|
|
||||||
private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) {
|
private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) {
|
||||||
if (getResult == GetResult.PASSWORD_MISMATCH) {
|
if (getResult == GetResult.PASSWORD_MISMATCH) {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
@ -52,7 +53,15 @@ import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.Arguments;
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
import org.junit.jupiter.params.provider.MethodSource;
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||||
|
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||||
|
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||||
|
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||||
|
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||||
|
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||||
|
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
|
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
|
||||||
|
@ -632,8 +641,14 @@ class SubscriptionControllerTest {
|
||||||
assertThat(response.getStatus()).isEqualTo(409);
|
assertThat(response.getStatus()).isEqualTo(409);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void setSubscriptionLevel() {
|
@CsvSource({
|
||||||
|
"5, M1",
|
||||||
|
"15, M2",
|
||||||
|
"35, M3",
|
||||||
|
"201, M4",
|
||||||
|
})
|
||||||
|
void setSubscriptionLevel(long levelId, String expectedProcessorId) {
|
||||||
// set up record
|
// set up record
|
||||||
final byte[] subscriberUserAndKey = new byte[32];
|
final byte[] subscriberUserAndKey = new byte[32];
|
||||||
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||||
|
@ -662,20 +677,19 @@ class SubscriptionControllerTest {
|
||||||
when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong()))
|
when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong()))
|
||||||
.thenReturn(CompletableFuture.completedFuture(null));
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
|
||||||
final long level = 5;
|
|
||||||
final Response response = RESOURCE_EXTENSION
|
final Response response = RESOURCE_EXTENSION
|
||||||
.target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, level, "usd", "abcd"))
|
.target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, levelId, "usd", "abcd"))
|
||||||
.request()
|
.request()
|
||||||
.put(Entity.json(""));
|
.put(Entity.json(""));
|
||||||
|
|
||||||
verify(BRAINTREE_MANAGER).createSubscription(eq(customerId), eq("M1"), eq(level), eq(0L));
|
verify(BRAINTREE_MANAGER).createSubscription(eq(customerId), eq(expectedProcessorId), eq(levelId), eq(0L));
|
||||||
verifyNoMoreInteractions(BRAINTREE_MANAGER);
|
verifyNoMoreInteractions(BRAINTREE_MANAGER);
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
|
||||||
assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class))
|
assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class))
|
||||||
.extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::level)
|
.extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::level)
|
||||||
.isEqualTo(level);
|
.isEqualTo(levelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
@ -752,10 +766,109 @@ class SubscriptionControllerTest {
|
||||||
Arguments.of("usd", 5, "usd", 5, false),
|
Arguments.of("usd", 5, "usd", 5, false),
|
||||||
Arguments.of("usd", 5, "jpy", 5, true),
|
Arguments.of("usd", 5, "jpy", 5, true),
|
||||||
Arguments.of("usd", 5, "usd", 15, true),
|
Arguments.of("usd", 5, "usd", 15, true),
|
||||||
Arguments.of("usd", 5, "jpy", 15, true)
|
Arguments.of("usd", 5, "jpy", 15, true),
|
||||||
|
Arguments.of("usd", 201, "usd", 201, false),
|
||||||
|
Arguments.of("usd", 201, "jpy", 201, true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void changeSubscriptionLevelInvalid() {
|
||||||
|
// set up record
|
||||||
|
final byte[] subscriberUserAndKey = new byte[32];
|
||||||
|
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||||
|
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
|
||||||
|
|
||||||
|
final String customerId = "customer";
|
||||||
|
final String existingSubscriptionId = "existingSubscription";
|
||||||
|
final Map<String, AttributeValue> dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]),
|
||||||
|
SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||||
|
SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),
|
||||||
|
SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID,
|
||||||
|
b(new ProcessorCustomer(customerId, SubscriptionProcessor.BRAINTREE).toDynamoBytes()),
|
||||||
|
SubscriptionManager.KEY_SUBSCRIPTION_ID, s(existingSubscriptionId));
|
||||||
|
final SubscriptionManager.Record record = SubscriptionManager.Record.from(
|
||||||
|
Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);
|
||||||
|
when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class)))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(record));
|
||||||
|
|
||||||
|
when(CLOCK.instant()).thenReturn(Instant.now());
|
||||||
|
when(SUBSCRIPTION_MANAGER.get(any(), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record)));
|
||||||
|
|
||||||
|
final Object subscriptionObj = new Object();
|
||||||
|
when(BRAINTREE_MANAGER.getSubscription(any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
|
||||||
|
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(
|
||||||
|
new SubscriptionProcessorManager.LevelAndCurrency(201, "usd")));
|
||||||
|
|
||||||
|
// Try to change from a backup subscription (201) to a donation subscription (5)
|
||||||
|
final Response response = RESOURCE_EXTENSION
|
||||||
|
.target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, 5, "usd", "abcd"))
|
||||||
|
.request()
|
||||||
|
.put(Entity.json(""));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(400);
|
||||||
|
assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelErrorResponse.class))
|
||||||
|
.extracting(resp -> resp.errors())
|
||||||
|
.asInstanceOf(InstanceOfAssertFactories.list(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.class))
|
||||||
|
.hasSize(1).first()
|
||||||
|
.extracting(error -> error.type())
|
||||||
|
.isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({"5, P45D", "201, P13D"})
|
||||||
|
public void createReceiptCredential(long level, Duration expectedExpirationWindow)
|
||||||
|
throws InvalidInputException, VerificationFailedException {
|
||||||
|
final byte[] subscriberUserAndKey = new byte[32];
|
||||||
|
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||||
|
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
|
||||||
|
|
||||||
|
final String customerId = "customer";
|
||||||
|
final String subscriptionId = "subscriptionId";
|
||||||
|
final Map<String, AttributeValue> dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]),
|
||||||
|
SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||||
|
SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),
|
||||||
|
SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID,
|
||||||
|
b(new ProcessorCustomer(customerId, SubscriptionProcessor.BRAINTREE).toDynamoBytes()),
|
||||||
|
SubscriptionManager.KEY_SUBSCRIPTION_ID, s(subscriptionId));
|
||||||
|
final SubscriptionManager.Record record = SubscriptionManager.Record.from(
|
||||||
|
Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);
|
||||||
|
final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(
|
||||||
|
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
|
||||||
|
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
|
||||||
|
final ReceiptCredentialResponse receiptCredentialResponse = mock(ReceiptCredentialResponse.class);
|
||||||
|
|
||||||
|
when(CLOCK.instant()).thenReturn(Instant.now());
|
||||||
|
when(SUBSCRIPTION_MANAGER.get(any(), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record)));
|
||||||
|
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
|
||||||
|
CompletableFuture.completedFuture(new SubscriptionProcessorManager.ReceiptItem(
|
||||||
|
"itemId",
|
||||||
|
Instant.ofEpochSecond(10).plus(Duration.ofDays(1)),
|
||||||
|
level
|
||||||
|
)));
|
||||||
|
when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(SubscriptionProcessor.BRAINTREE), eq(receiptRequest), any()))
|
||||||
|
.thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
when(ZK_OPS.issueReceiptCredential(any(), anyLong(), eq(level))).thenReturn(receiptCredentialResponse);
|
||||||
|
when(receiptCredentialResponse.serialize()).thenReturn(new byte[0]);
|
||||||
|
final Response response = RESOURCE_EXTENSION
|
||||||
|
.target(String.format("/v1/subscription/%s/receipt_credentials", subscriberId))
|
||||||
|
.request()
|
||||||
|
.post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize())));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
|
||||||
|
long expectedExpiration = Instant.EPOCH
|
||||||
|
// Truncated current time is day 1
|
||||||
|
.plus(Duration.ofDays(1))
|
||||||
|
// Expected expiration window
|
||||||
|
.plus(expectedExpirationWindow)
|
||||||
|
// + one day to forgive skew
|
||||||
|
.plus(Duration.ofDays(1)).getEpochSecond();
|
||||||
|
verify(ZK_OPS).issueReceiptCredential(any(), eq(expectedExpiration), eq(level));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetBankMandate() {
|
void testGetBankMandate() {
|
||||||
when(BANK_MANDATE_TRANSLATOR.translate(any(), any())).thenReturn("bankMandate");
|
when(BANK_MANDATE_TRANSLATOR.translate(any(), any())).thenReturn("bankMandate");
|
||||||
|
@ -812,6 +925,7 @@ class SubscriptionControllerTest {
|
||||||
List.of(BigDecimal.valueOf(20))));
|
List.of(BigDecimal.valueOf(20))));
|
||||||
assertThat(currency.subscription()).isEqualTo(
|
assertThat(currency.subscription()).isEqualTo(
|
||||||
Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15), "35", BigDecimal.valueOf(35)));
|
Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15), "35", BigDecimal.valueOf(35)));
|
||||||
|
assertThat(currency.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(5)));
|
||||||
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL"));
|
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -826,6 +940,7 @@ class SubscriptionControllerTest {
|
||||||
List.of(BigDecimal.valueOf(2000))));
|
List.of(BigDecimal.valueOf(2000))));
|
||||||
assertThat(currency.subscription()).isEqualTo(
|
assertThat(currency.subscription()).isEqualTo(
|
||||||
Map.of("5", BigDecimal.valueOf(500), "15", BigDecimal.valueOf(1500), "35", BigDecimal.valueOf(3500)));
|
Map.of("5", BigDecimal.valueOf(500), "15", BigDecimal.valueOf(1500), "35", BigDecimal.valueOf(3500)));
|
||||||
|
assertThat(currency.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(500)));
|
||||||
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL"));
|
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -840,6 +955,7 @@ class SubscriptionControllerTest {
|
||||||
List.of(BigDecimal.valueOf(20000))));
|
List.of(BigDecimal.valueOf(20000))));
|
||||||
assertThat(currency.subscription()).isEqualTo(
|
assertThat(currency.subscription()).isEqualTo(
|
||||||
Map.of("5", BigDecimal.valueOf(5000), "15", BigDecimal.valueOf(15000), "35", BigDecimal.valueOf(35000)));
|
Map.of("5", BigDecimal.valueOf(5000), "15", BigDecimal.valueOf(15000), "35", BigDecimal.valueOf(35000)));
|
||||||
|
assertThat(currency.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(5000)));
|
||||||
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD"));
|
assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -852,7 +968,8 @@ class SubscriptionControllerTest {
|
||||||
BigDecimal.valueOf(20), BigDecimal.valueOf(30), BigDecimal.valueOf(50), BigDecimal.valueOf(100)), "100",
|
BigDecimal.valueOf(20), BigDecimal.valueOf(30), BigDecimal.valueOf(50), BigDecimal.valueOf(100)), "100",
|
||||||
List.of(BigDecimal.valueOf(5))));
|
List.of(BigDecimal.valueOf(5))));
|
||||||
assertThat(currency.subscription()).isEqualTo(
|
assertThat(currency.subscription()).isEqualTo(
|
||||||
Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15),"35", BigDecimal.valueOf(35)));
|
Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15), "35", BigDecimal.valueOf(35)));
|
||||||
|
assertThat(currency.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(5)));
|
||||||
final List<String> expectedPaymentMethods = List.of("CARD", "SEPA_DEBIT", "IDEAL");
|
final List<String> expectedPaymentMethods = List.of("CARD", "SEPA_DEBIT", "IDEAL");
|
||||||
assertThat(currency.supportedPaymentMethods()).isEqualTo(expectedPaymentMethods);
|
assertThat(currency.supportedPaymentMethods()).isEqualTo(expectedPaymentMethods);
|
||||||
});
|
});
|
||||||
|
@ -950,6 +1067,30 @@ class SubscriptionControllerTest {
|
||||||
private static final String SUBSCRIPTION_CONFIG_YAML = """
|
private static final String SUBSCRIPTION_CONFIG_YAML = """
|
||||||
badgeExpiration: P30D
|
badgeExpiration: P30D
|
||||||
badgeGracePeriod: P15D
|
badgeGracePeriod: P15D
|
||||||
|
backupExpiration: P13D
|
||||||
|
backupLevels:
|
||||||
|
201:
|
||||||
|
prices:
|
||||||
|
usd:
|
||||||
|
amount: '5'
|
||||||
|
processorIds:
|
||||||
|
STRIPE: R4
|
||||||
|
BRAINTREE: M4
|
||||||
|
jpy:
|
||||||
|
amount: '500'
|
||||||
|
processorIds:
|
||||||
|
STRIPE: Q4
|
||||||
|
BRAINTREE: N4
|
||||||
|
bif:
|
||||||
|
amount: '5000'
|
||||||
|
processorIds:
|
||||||
|
STRIPE: S4
|
||||||
|
BRAINTREE: O4
|
||||||
|
eur:
|
||||||
|
amount: '5'
|
||||||
|
processorIds:
|
||||||
|
STRIPE: A4
|
||||||
|
BRAINTREE: B4
|
||||||
levels:
|
levels:
|
||||||
5:
|
5:
|
||||||
badge: B1
|
badge: B1
|
||||||
|
|
Loading…
Reference in New Issue