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 801ad0071..5a15c0fa6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -8,7 +8,9 @@ package org.whispersystems.textsecuregcm.controllers; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.google.common.annotations.VisibleForTesting; +import com.google.common.net.HttpHeaders; import com.stripe.exception.StripeException; +import com.vdurmont.semver4j.Semver; import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; @@ -33,6 +35,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.stream.Collectors; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.validation.Valid; @@ -60,7 +63,6 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.Context; -import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; @@ -94,6 +96,10 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.util.ExactlySize; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; +import org.whispersystems.textsecuregcm.util.ua.UserAgent; +import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; @Path("/v1/subscription") @io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions") @@ -111,14 +117,13 @@ public class SubscriptionController { private final IssuedReceiptsManager issuedReceiptsManager; private final BadgeTranslator badgeTranslator; private final LevelTranslator levelTranslator; - private final Map currencyConfiguration; - private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "invalidAcceptLanguage"); 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 EURO_CURRENCY_CODE = "EUR"; + private static final Semver LAST_PROBLEMATIC_IOS_VERSION = new Semver("6.44.0"); public SubscriptionController( @Nonnull Clock clock, @@ -141,16 +146,10 @@ public class SubscriptionController { 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, braintreeManager)); } - private static Map buildCurrencyConfiguration( - OneTimeDonationConfiguration oneTimeDonationConfiguration, - SubscriptionConfiguration subscriptionConfiguration, - List subscriptionProcessorManagers) { - + private Map buildCurrencyConfiguration(@Nullable final UserAgent userAgent) { + final List subscriptionProcessorManagers = List.of(stripeManager, braintreeManager); return oneTimeDonationConfiguration.currencies() .entrySet().stream() .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> { @@ -170,6 +169,7 @@ public class SubscriptionController { levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount())); final List supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) + .filter(paymentMethod -> !excludePaymentMethod(userAgent, paymentMethod)) .filter(paymentMethod -> subscriptionProcessorManagers.stream() .anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod) && manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency))) @@ -185,9 +185,18 @@ public class SubscriptionController { })); } - @VisibleForTesting - GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(List acceptableLanguages) { + // This logic to exclude some iOS client versions from receiving SEPA_DEBIT + // as a supported payment method can be removed after 01-23-24. + private boolean excludePaymentMethod(@Nullable final UserAgent userAgent, final PaymentMethod paymentMethod) { + return paymentMethod == PaymentMethod.SEPA_DEBIT + && userAgent != null + && userAgent.getPlatform() == ClientPlatform.IOS + && userAgent.getVersion().isLowerThanOrEqualTo(LAST_PROBLEMATIC_IOS_VERSION); + } + @VisibleForTesting + GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(final List acceptableLanguages, + final UserAgent userAgent) { final Map levels = new HashMap<>(); subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> { @@ -215,7 +224,7 @@ public class SubscriptionController { giftBadge, oneTimeDonationConfiguration.gift().expiration()))); - return new GetSubscriptionConfigurationResponse(currencyConfiguration, levels); + return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(userAgent), levels); } @DELETE @@ -563,10 +572,18 @@ public class SubscriptionController { @GET @Path("/configuration") @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture getConfiguration(@Context ContainerRequestContext containerRequestContext) { + public CompletableFuture getConfiguration(@Context ContainerRequestContext containerRequestContext, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString) { return CompletableFuture.supplyAsync(() -> { List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); - return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build(); + + UserAgent userAgent; + try { + userAgent = UserAgentUtil.parseUserAgentString(userAgentString); + } catch (UnrecognizedUserAgentException e) { + userAgent = null; + } + return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages, userAgent)).build(); }); } 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 ada096048..1594de87e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -42,6 +42,7 @@ import java.util.function.Predicate; import java.util.stream.Stream; import javax.ws.rs.client.Entity; import javax.ws.rs.core.Response; +import org.apache.http.HttpHeaders; import org.assertj.core.api.InstanceOfAssertFactories; import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; @@ -92,22 +93,6 @@ class SubscriptionControllerTest { private static final StripeManager STRIPE_MANAGER = mock(StripeManager.class); private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class); private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class); - - static { - // this behavior is required by the SubscriptionController constructor - List.of(STRIPE_MANAGER, BRAINTREE_MANAGER) - .forEach(manager -> { - when(manager.supportsPaymentMethod(any())) - .thenCallRealMethod(); - }); - when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) - .thenReturn(Set.of("usd", "jpy", "bif", "eur")); - when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT)) - .thenReturn(Set.of("eur")); - when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL)) - .thenReturn(Set.of("usd", "jpy")); - } - 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); @@ -134,6 +119,18 @@ class SubscriptionControllerTest { when(STRIPE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.STRIPE); when(BRAINTREE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.BRAINTREE); + + List.of(STRIPE_MANAGER, BRAINTREE_MANAGER) + .forEach(manager -> { + when(manager.supportsPaymentMethod(any())) + .thenCallRealMethod(); + }); + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) + .thenReturn(Set.of("usd", "jpy", "bif", "eur")); + when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT)) + .thenReturn(Set.of("eur")); + when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL)) + .thenReturn(Set.of("usd", "jpy")); } @Test @@ -713,8 +710,9 @@ class SubscriptionControllerTest { ); } - @Test - void getSubscriptionConfiguration() { + @ParameterizedTest + @MethodSource + void getSubscriptionConfiguration(final String userAgent, final boolean expectSepa) { 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")))); @@ -736,6 +734,7 @@ class SubscriptionControllerTest { GetSubscriptionConfigurationResponse response = RESOURCE_EXTENSION.target("/v1/subscription/configuration") .request() + .header(HttpHeaders.USER_AGENT, userAgent) .get(GetSubscriptionConfigurationResponse.class); assertThat(response.currencies()).containsKeys("usd", "jpy", "bif", "eur").satisfies(currencyMap -> { @@ -791,7 +790,8 @@ class SubscriptionControllerTest { List.of(BigDecimal.valueOf(5)))); 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", "SEPA_DEBIT")); + final List expectedPaymentMethods = expectSepa ? List.of("CARD", "SEPA_DEBIT") : List.of("CARD"); + assertThat(currency.supportedPaymentMethods()).isEqualTo(expectedPaymentMethods); }); }); @@ -841,6 +841,7 @@ class SubscriptionControllerTest { // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration` Map genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") .request() + .header(HttpHeaders.USER_AGENT, userAgent) .get(Map.class); assertThat(genericResponse.get("levels")).satisfies(levels -> { @@ -863,6 +864,19 @@ class SubscriptionControllerTest { }); } + private static Stream getSubscriptionConfiguration() { + return Stream.of( + Arguments.of("Signal-iOS/6.44.0.8", false), + Arguments.of("Signal-iOS/6.45.0.0", true), + Arguments.of("Signal-iOS/6.45.0.2", true), + Arguments.of("Signal-iOS/6.46.0.0", true), + Arguments.of("Signal-Android/1.2.3", true), + Arguments.of(null, true), + Arguments.of("", true), + Arguments.of("definitely not a parseable user agent", true) + ); + } + /** * Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references */