Exclude `SEPA_DEBIT` as a supported payment method for certain iOS client versions

This commit is contained in:
Katherine 2023-10-03 11:34:52 -07:00 committed by GitHub
parent 6522b74e20
commit c43e0b54f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 66 additions and 35 deletions

View File

@ -8,7 +8,9 @@ package org.whispersystems.textsecuregcm.controllers;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders;
import com.stripe.exception.StripeException; import com.stripe.exception.StripeException;
import com.vdurmont.semver4j.Semver;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tag;
@ -33,6 +35,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.Mac; import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import javax.validation.Valid; import javax.validation.Valid;
@ -60,7 +63,6 @@ import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; 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.SubscriptionProcessor;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
import org.whispersystems.textsecuregcm.util.ExactlySize; 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") @Path("/v1/subscription")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions") @io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions")
@ -111,14 +117,13 @@ public class SubscriptionController {
private final IssuedReceiptsManager issuedReceiptsManager; private final IssuedReceiptsManager issuedReceiptsManager;
private final BadgeTranslator badgeTranslator; private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator; private final LevelTranslator levelTranslator;
private final Map<String, CurrencyConfiguration> currencyConfiguration;
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class,
"invalidAcceptLanguage"); "invalidAcceptLanguage");
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 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");
public SubscriptionController( public SubscriptionController(
@Nonnull Clock clock, @Nonnull Clock clock,
@ -141,16 +146,10 @@ public class SubscriptionController {
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator); this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator); this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration,
this.subscriptionConfiguration, List.of(stripeManager, braintreeManager));
} }
private static Map<String, CurrencyConfiguration> buildCurrencyConfiguration( private Map<String, CurrencyConfiguration> buildCurrencyConfiguration(@Nullable final UserAgent userAgent) {
OneTimeDonationConfiguration oneTimeDonationConfiguration, final List<SubscriptionProcessorManager> subscriptionProcessorManagers = List.of(stripeManager, braintreeManager);
SubscriptionConfiguration subscriptionConfiguration,
List<SubscriptionProcessorManager> subscriptionProcessorManagers) {
return oneTimeDonationConfiguration.currencies() return oneTimeDonationConfiguration.currencies()
.entrySet().stream() .entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> { .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
@ -170,6 +169,7 @@ public class SubscriptionController {
levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount())); levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount()));
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
.filter(paymentMethod -> !excludePaymentMethod(userAgent, paymentMethod))
.filter(paymentMethod -> subscriptionProcessorManagers.stream() .filter(paymentMethod -> subscriptionProcessorManagers.stream()
.anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod) .anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod)
&& manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency))) && manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency)))
@ -185,9 +185,18 @@ public class SubscriptionController {
})); }));
} }
@VisibleForTesting // This logic to exclude some iOS client versions from receiving SEPA_DEBIT
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(List<Locale> acceptableLanguages) { // 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<Locale> acceptableLanguages,
final UserAgent userAgent) {
final Map<String, LevelConfiguration> levels = new HashMap<>(); final Map<String, LevelConfiguration> levels = new HashMap<>();
subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> { subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> {
@ -215,7 +224,7 @@ public class SubscriptionController {
giftBadge, giftBadge,
oneTimeDonationConfiguration.gift().expiration()))); oneTimeDonationConfiguration.gift().expiration())));
return new GetSubscriptionConfigurationResponse(currencyConfiguration, levels); return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(userAgent), levels);
} }
@DELETE @DELETE
@ -563,10 +572,18 @@ public class SubscriptionController {
@GET @GET
@Path("/configuration") @Path("/configuration")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext) { public CompletableFuture<Response> getConfiguration(@Context ContainerRequestContext containerRequestContext,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
List<Locale> acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); List<Locale> 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();
}); });
} }

View File

@ -42,6 +42,7 @@ import java.util.function.Predicate;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.apache.http.HttpHeaders;
import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.InstanceOfAssertFactories;
import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; 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 StripeManager STRIPE_MANAGER = mock(StripeManager.class);
private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class); private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class);
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.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 ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
@ -134,6 +119,18 @@ class SubscriptionControllerTest {
when(STRIPE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.STRIPE); when(STRIPE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.STRIPE);
when(BRAINTREE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.BRAINTREE); 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 @Test
@ -713,8 +710,9 @@ class SubscriptionControllerTest {
); );
} }
@Test @ParameterizedTest
void getSubscriptionConfiguration() { @MethodSource
void getSubscriptionConfiguration(final String userAgent, final boolean expectSepa) {
when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1", 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("l", "m", "h", "x", "xx", "xxx"), "SVG",
List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); 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") GetSubscriptionConfigurationResponse response = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
.request() .request()
.header(HttpHeaders.USER_AGENT, userAgent)
.get(GetSubscriptionConfigurationResponse.class); .get(GetSubscriptionConfigurationResponse.class);
assertThat(response.currencies()).containsKeys("usd", "jpy", "bif", "eur").satisfies(currencyMap -> { assertThat(response.currencies()).containsKeys("usd", "jpy", "bif", "eur").satisfies(currencyMap -> {
@ -791,7 +790,8 @@ class SubscriptionControllerTest {
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.supportedPaymentMethods()).isEqualTo(List.of("CARD", "SEPA_DEBIT")); final List<String> 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` // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration`
Map<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") Map<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
.request() .request()
.header(HttpHeaders.USER_AGENT, userAgent)
.get(Map.class); .get(Map.class);
assertThat(genericResponse.get("levels")).satisfies(levels -> { assertThat(genericResponse.get("levels")).satisfies(levels -> {
@ -863,6 +864,19 @@ class SubscriptionControllerTest {
}); });
} }
private static Stream<Arguments> 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 * Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references
*/ */