diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java index 9d29c4540..85dc76b6e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java @@ -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 levels; + + private final Duration backupExpiration; + private final Map donationLevels; + private final Map 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 getLevels() { - return levels; + public Duration getBackupExpiration() { + return backupExpiration; + } + + public SubscriptionLevelConfiguration getSubscriptionLevel(long level) { + return Optional + .ofNullable(this.donationLevels.get(level)) + .orElse(this.backupLevels.get(level)); + } + + public Map getDonationLevels() { + return donationLevels; + } + + public Map 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 any = levels.values().stream().findAny(); + final Map subscriptionLevels = Stream + .concat(donationLevels.entrySet().stream(), backupLevels.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Optional any = subscriptionLevels.values().stream().findAny(); if (any.isEmpty()) { return true; } - Set currencies = any.get().getPrices().keySet(); - return levels.values().stream().allMatch(level -> currencies.equals(level.getPrices().keySet())); + Set currencies = any.get().prices().keySet(); + return subscriptionLevels.values().stream().allMatch(level -> currencies.equals(level.prices().keySet())); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java index c410295b8..0bac40303 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java @@ -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 prices; + Map 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 getPrices() { - return prices; - } + @JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) + implements SubscriptionLevelConfiguration {} } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index 25e72f688..5ce541753 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -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 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> 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 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 acceptableLanguages) { + GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse( + final List acceptableLanguages) { final Map 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> oneTime, Map subscription, + Map backupSubscription, List 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 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) { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 77208f5bb..8bd7aa512 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -28,6 +28,7 @@ import io.dropwizard.testing.junit5.ResourceExtension; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; 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.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; 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.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.badges.BadgeTranslator; @@ -632,8 +641,14 @@ class SubscriptionControllerTest { assertThat(response.getStatus()).isEqualTo(409); } - @Test - void setSubscriptionLevel() { + @ParameterizedTest + @CsvSource({ + "5, M1", + "15, M2", + "35, M3", + "201, M4", + }) + void setSubscriptionLevel(long levelId, String expectedProcessorId) { // set up record final byte[] subscriberUserAndKey = new byte[32]; Arrays.fill(subscriberUserAndKey, (byte) 1); @@ -662,20 +677,19 @@ class SubscriptionControllerTest { when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong())) .thenReturn(CompletableFuture.completedFuture(null)); - final long level = 5; 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() .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); assertThat(response.getStatus()).isEqualTo(200); assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class)) .extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::level) - .isEqualTo(level); + .isEqualTo(levelId); } @ParameterizedTest @@ -752,10 +766,109 @@ class SubscriptionControllerTest { Arguments.of("usd", 5, "usd", 5, false), Arguments.of("usd", 5, "jpy", 5, 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 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 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 void testGetBankMandate() { when(BANK_MANDATE_TRANSLATOR.translate(any(), any())).thenReturn("bankMandate"); @@ -812,6 +925,7 @@ class SubscriptionControllerTest { 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.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(5))); assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL")); }); @@ -826,6 +940,7 @@ class SubscriptionControllerTest { 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.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(500))); assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL")); }); @@ -840,6 +955,7 @@ class SubscriptionControllerTest { 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.backupSubscription()).isEqualTo(Map.of("201", BigDecimal.valueOf(5000))); 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", List.of(BigDecimal.valueOf(5)))); 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 expectedPaymentMethods = List.of("CARD", "SEPA_DEBIT", "IDEAL"); assertThat(currency.supportedPaymentMethods()).isEqualTo(expectedPaymentMethods); }); @@ -950,6 +1067,30 @@ class SubscriptionControllerTest { private static final String SUBSCRIPTION_CONFIG_YAML = """ badgeExpiration: P30D 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: 5: badge: B1