mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 09:28:03 +01:00
Add backup levels to subscription configuration response
This commit is contained in:
@@ -8,11 +8,15 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.collect.Sets;
|
||||
import io.dropwizard.validation.ValidationMethod;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
@@ -21,16 +25,23 @@ public class SubscriptionConfiguration {
|
||||
|
||||
private final Duration badgeGracePeriod;
|
||||
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
|
||||
public SubscriptionConfiguration(
|
||||
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
|
||||
@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.badgeExpiration = badgeExpiration;
|
||||
this.levels = levels;
|
||||
this.donationLevels = donationLevels;
|
||||
this.backupExpiration = backupExpiration;
|
||||
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
|
||||
}
|
||||
|
||||
public Duration getBadgeGracePeriod() {
|
||||
@@ -42,19 +53,43 @@ public class SubscriptionConfiguration {
|
||||
return badgeExpiration;
|
||||
}
|
||||
|
||||
public Map<Long, SubscriptionLevelConfiguration> getLevels() {
|
||||
return levels;
|
||||
public Duration getBackupExpiration() {
|
||||
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
|
||||
@ValidationMethod(message = "has a mismatch between the levels supported currencies")
|
||||
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()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Set<String> currencies = any.get().getPrices().keySet();
|
||||
return levels.values().stream().allMatch(level -> currencies.equals(level.getPrices().keySet()));
|
||||
Set<String> currencies = any.get().prices().keySet();
|
||||
return subscriptionLevels.values().stream().allMatch(level -> currencies.equals(level.prices().keySet()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,35 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class SubscriptionLevelConfiguration {
|
||||
public sealed interface SubscriptionLevelConfiguration permits
|
||||
SubscriptionLevelConfiguration.Backup, SubscriptionLevelConfiguration.Donation {
|
||||
|
||||
private final String badge;
|
||||
private final Map<String, SubscriptionPriceConfiguration> prices;
|
||||
Map<String, SubscriptionPriceConfiguration> prices();
|
||||
|
||||
@JsonCreator
|
||||
public SubscriptionLevelConfiguration(
|
||||
enum Type {
|
||||
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("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) {
|
||||
this.badge = badge;
|
||||
this.prices = prices;
|
||||
}
|
||||
|
||||
public String getBadge() {
|
||||
return badge;
|
||||
}
|
||||
|
||||
public Map<String, SubscriptionPriceConfiguration> getPrices() {
|
||||
return prices;
|
||||
}
|
||||
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices)
|
||||
implements SubscriptionLevelConfiguration {}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
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 PROCESSOR_TAG_NAME = "processor";
|
||||
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 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())
|
||||
);
|
||||
|
||||
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).amount()));
|
||||
final Function<Map<Long, ? extends SubscriptionLevelConfiguration>, Map<String, BigDecimal>> extractSubscriptionAmounts = levels ->
|
||||
levels.entrySet().stream()
|
||||
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().prices().containsKey(currency))
|
||||
.collect(Collectors.toMap(
|
||||
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
|
||||
levelIdAndConfig -> levelIdAndConfig.getValue().prices().get(currency).amount()));
|
||||
|
||||
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
|
||||
.filter(paymentMethod -> subscriptionProcessorManagers.stream()
|
||||
@@ -184,19 +186,24 @@ public class SubscriptionController {
|
||||
throw new RuntimeException("Configuration has currency with no processor support: " + currency);
|
||||
}
|
||||
|
||||
return new CurrencyConfiguration(currencyConfig.minimum(), oneTimeLevelsToSuggestedAmounts,
|
||||
subscriptionLevelsToAmounts, supportedPaymentMethods);
|
||||
return new CurrencyConfiguration(
|
||||
currencyConfig.minimum(),
|
||||
oneTimeLevelsToSuggestedAmounts,
|
||||
extractSubscriptionAmounts.apply(subscriptionConfiguration.getDonationLevels()),
|
||||
extractSubscriptionAmounts.apply(subscriptionConfiguration.getBackupLevels()),
|
||||
supportedPaymentMethods);
|
||||
}));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(final List<Locale> acceptableLanguages) {
|
||||
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(
|
||||
final List<Locale> acceptableLanguages) {
|
||||
final Map<String, LevelConfiguration> levels = new HashMap<>();
|
||||
|
||||
subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> {
|
||||
subscriptionConfiguration.getDonationLevels().forEach((levelId, levelConfig) -> {
|
||||
final LevelConfiguration levelConfiguration = new LevelConfiguration(
|
||||
levelTranslator.translate(acceptableLanguages, levelConfig.getBadge()),
|
||||
badgeTranslator.translate(acceptableLanguages, levelConfig.getBadge()));
|
||||
levelTranslator.translate(acceptableLanguages, levelConfig.badge()),
|
||||
badgeTranslator.translate(acceptableLanguages, levelConfig.badge()));
|
||||
levels.put(String.valueOf(levelId), levelConfiguration);
|
||||
});
|
||||
|
||||
@@ -478,6 +485,13 @@ public class SubscriptionController {
|
||||
currency.toLowerCase(Locale.ROOT)))) {
|
||||
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(
|
||||
subscription, subscriptionTemplateId, level, idempotencyKey)
|
||||
.thenCompose(updatedSubscription ->
|
||||
@@ -519,6 +533,11 @@ public class SubscriptionController {
|
||||
.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
|
||||
*
|
||||
@@ -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
|
||||
* presented
|
||||
* @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
|
||||
*/
|
||||
public record CurrencyConfiguration(BigDecimal minimum, Map<String, List<BigDecimal>> oneTime,
|
||||
Map<String, BigDecimal> subscription,
|
||||
Map<String, BigDecimal> backupSubscription,
|
||||
List<String> supportedPaymentMethods) {
|
||||
|
||||
}
|
||||
@@ -946,7 +967,8 @@ public class SubscriptionController {
|
||||
try {
|
||||
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
||||
receiptCredentialRequest,
|
||||
receiptExpirationWithGracePeriod(receipt.paidAt()).getEpochSecond(), receipt.level());
|
||||
receiptExpirationWithGracePeriod(receipt.paidAt(), receipt.level()).getEpochSecond(),
|
||||
receipt.level());
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new BadRequestException("receipt credential request failed verification", e);
|
||||
}
|
||||
@@ -954,6 +976,9 @@ public class SubscriptionController {
|
||||
Tags.of(
|
||||
Tag.of(PROCESSOR_TAG_NAME, manager.getProcessor().toString()),
|
||||
Tag.of(TYPE_TAG_NAME, "subscription"),
|
||||
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
|
||||
subscriptionConfiguration.getSubscriptionLevel(receipt.level()).type().name()
|
||||
.toLowerCase(Locale.ROOT)),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.increment();
|
||||
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize()))
|
||||
@@ -989,32 +1014,38 @@ public class SubscriptionController {
|
||||
new ClientErrorException(Status.CONFLICT)))
|
||||
.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) {
|
||||
SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level);
|
||||
if (levelConfiguration == null) {
|
||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||
new SetSubscriptionLevelErrorResponse.Error(
|
||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
||||
.build());
|
||||
}
|
||||
private Instant receiptExpirationWithGracePeriod(Instant paidAt, long level) {
|
||||
return switch (subscriptionConfiguration.getSubscriptionLevel(level).type()) {
|
||||
case DONATION -> paidAt.plus(subscriptionConfiguration.getBadgeExpiration())
|
||||
.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
||||
.truncatedTo(ChronoUnit.DAYS)
|
||||
.plus(1, ChronoUnit.DAYS);
|
||||
case BACKUP -> paidAt.plus(subscriptionConfiguration.getBackupExpiration())
|
||||
.truncatedTo(ChronoUnit.DAYS)
|
||||
.plus(1, ChronoUnit.DAYS);
|
||||
};
|
||||
}
|
||||
|
||||
return Optional.ofNullable(levelConfiguration.getPrices()
|
||||
.get(currency.toLowerCase(Locale.ROOT)))
|
||||
.map(priceConfiguration -> priceConfiguration.processorIds().get(processor))
|
||||
.orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||
new SetSubscriptionLevelErrorResponse.Error(
|
||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
|
||||
.build()));
|
||||
|
||||
private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) {
|
||||
final SubscriptionLevelConfiguration config = subscriptionConfiguration.getSubscriptionLevel(level);
|
||||
if (config == null) {
|
||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||
new SetSubscriptionLevelErrorResponse.Error(
|
||||
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) {
|
||||
if (getResult == GetResult.PASSWORD_MISMATCH) {
|
||||
|
||||
Reference in New Issue
Block a user