diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java index 1c12ad3d6..a103ffc74 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java @@ -46,8 +46,6 @@ public class DonationController { ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException; } - private static final Logger logger = LoggerFactory.getLogger(DonationController.class); - private final Clock clock; private final ServerZkReceiptOperations serverZkReceiptOperations; private final RedeemedReceiptsManager redeemedReceiptsManager; 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 5ce541753..1c4425375 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -10,7 +10,6 @@ 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; @@ -36,6 +35,7 @@ import java.util.concurrent.CompletionException; import java.util.function.Function; 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; @@ -99,6 +99,9 @@ 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.UserAgentUtil; import org.whispersystems.websocket.auth.ReadOnly; @Path("/v1/subscription") @@ -126,7 +129,6 @@ public class SubscriptionController { 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"); public SubscriptionController( @Nonnull Clock clock, @@ -290,7 +292,8 @@ public class SubscriptionController { public CompletableFuture createPaymentMethod( @ReadOnly @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId, - @QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType) { + @QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType, + @HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) { RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); @@ -309,7 +312,7 @@ public class SubscriptionController { return CompletableFuture.completedFuture(record); }) - .orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser) + .orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser, getClientPlatform(userAgentString)) .thenApply(ProcessorCustomer::customerId) .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, new ProcessorCustomer(customerId, subscriptionProcessorManager.getProcessor()), @@ -345,7 +348,8 @@ public class SubscriptionController { @ReadOnly @Auth Optional authenticatedAccount, @PathParam("subscriberId") String subscriberId, @NotNull @Valid CreatePayPalBillingAgreementRequest request, - @Context ContainerRequestContext containerRequestContext) { + @Context ContainerRequestContext containerRequestContext, + @HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) { RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); @@ -362,7 +366,7 @@ public class SubscriptionController { } return CompletableFuture.completedFuture(record); }) - .orElseGet(() -> braintreeManager.createCustomer(requestData.subscriberUser) + .orElseGet(() -> braintreeManager.createCustomer(requestData.subscriberUser, getClientPlatform(userAgentString)) .thenApply(ProcessorCustomer::customerId) .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, new ProcessorCustomer(customerId, braintreeManager.getProcessor()), @@ -665,7 +669,9 @@ public class SubscriptionController { @Path("/boost/create") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request) { + public CompletableFuture createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { + return CompletableFuture.runAsync(() -> { if (request.level == null) { request.level = oneTimeDonationConfiguration.boost().level(); @@ -683,7 +689,7 @@ public class SubscriptionController { } validateRequestCurrencyAmount(request, amount, stripeManager); }) - .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level)) + .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level, getClientPlatform(userAgent))) .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); } @@ -769,7 +775,8 @@ public class SubscriptionController { @Path("/boost/paypal/confirm") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture confirmPayPalBoost(@NotNull @Valid ConfirmPayPalBoostRequest request) { + public CompletableFuture confirmPayPalBoost(@NotNull @Valid ConfirmPayPalBoostRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) { return CompletableFuture.runAsync(() -> { if (request.level == null) { @@ -1072,6 +1079,15 @@ public class SubscriptionController { } } + @Nullable + private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) { + try { + return UserAgentUtil.parseUserAgentString(userAgentString).getPlatform(); + } catch (final UnrecognizedUserAgentException e) { + return null; + } + } + private record RequestData(@Nonnull byte[] subscriberBytes, @Nonnull byte[] subscriberUser, @Nonnull byte[] subscriberKey, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java index c0a5ef221..54dfcc440 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -44,6 +44,7 @@ import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; public class BraintreeManager implements SubscriptionProcessorManager { @@ -252,10 +253,15 @@ public class BraintreeManager implements SubscriptionProcessorManager { } @Override - public CompletableFuture createCustomer(final byte[] subscriberUser) { + public CompletableFuture createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) { return CompletableFuture.supplyAsync(() -> { - final CustomerRequest request = new CustomerRequest() + CustomerRequest request = new CustomerRequest() .customField("subscriber_user", HexFormat.of().formatHex(subscriberUser)); + + if (clientPlatform != null) { + request.customField("client_platform", clientPlatform.name().toLowerCase()); + } + try { return braintreeGateway.customer().create(request); } catch (BraintreeException e) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java index 6de84417f..535d13834 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java @@ -74,10 +74,12 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.util.Conversions; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; public class StripeManager implements SubscriptionProcessorManager { private static final Logger logger = LoggerFactory.getLogger(StripeManager.class); private static final String METADATA_KEY_LEVEL = "level"; + private static final String METADATA_KEY_CLIENT_PLATFORM = "clientPlatform"; private final StripeClient stripeClient; private final Executor executor; @@ -127,14 +129,18 @@ public class StripeManager implements SubscriptionProcessorManager { } @Override - public CompletableFuture createCustomer(byte[] subscriberUser) { + public CompletableFuture createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) { return CompletableFuture.supplyAsync(() -> { - CustomerCreateParams params = CustomerCreateParams.builder() - .putMetadata("subscriberUser", HexFormat.of().formatHex(subscriberUser)) - .build(); + final CustomerCreateParams.Builder builder = CustomerCreateParams.builder() + .putMetadata("subscriberUser", HexFormat.of().formatHex(subscriberUser)); + + if (clientPlatform != null) { + builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase()); + } + try { return stripeClient.customers() - .create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser))); + .create(builder.build(), commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser))); } catch (StripeException e) { throw new CompletionException(e); } @@ -194,16 +200,24 @@ public class StripeManager implements SubscriptionProcessorManager { /** * Creates a payment intent. May throw a 400 WebApplicationException if the amount is too small. */ - public CompletableFuture createPaymentIntent(String currency, long amount, long level) { + public CompletableFuture createPaymentIntent(final String currency, + final long amount, + final long level, + @Nullable final ClientPlatform clientPlatform) { + return CompletableFuture.supplyAsync(() -> { - PaymentIntentCreateParams params = PaymentIntentCreateParams.builder() + final PaymentIntentCreateParams.Builder builder = PaymentIntentCreateParams.builder() .setAmount(amount) .setCurrency(currency.toLowerCase(Locale.ROOT)) .setDescription(boostDescription) - .putMetadata("level", Long.toString(level)) - .build(); + .putMetadata("level", Long.toString(level)); + + if (clientPlatform != null) { + builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase()); + } + try { - return stripeClient.paymentIntents().create(params, commonOptions()); + return stripeClient.paymentIntents().create(builder.build(), commonOptions()); } catch (StripeException e) { if ("amount_too_small".equalsIgnoreCase(e.getCode())) { throw new WebApplicationException(Response diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java index febd2858d..aafd4794e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java @@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; public interface SubscriptionProcessorManager { SubscriptionProcessor getProcessor(); @@ -23,7 +24,7 @@ public interface SubscriptionProcessorManager { CompletableFuture getPaymentDetails(String paymentId); - CompletableFuture createCustomer(byte[] subscriberUser); + CompletableFuture createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform); CompletableFuture createPaymentMethodSetupToken(String customerId); 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 8bd7aa512..4d1fd7b84 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -231,7 +231,7 @@ class SubscriptionControllerTest { void testCreateBoostPaymentIntent() { when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD)) .thenReturn(Set.of("usd", "jpy", "bif", "eur")); - when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong())) + when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong(), any())) .thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT)); String clientSecret = "some_client_secret"; @@ -584,7 +584,7 @@ class SubscriptionControllerTest { final String customerId = "some-customer-id"; final ProcessorCustomer customer = new ProcessorCustomer( customerId, SubscriptionProcessor.STRIPE); - when(STRIPE_MANAGER.createCustomer(any())) + when(STRIPE_MANAGER.createCustomer(any(), any())) .thenReturn(CompletableFuture.completedFuture(customer)); final Map dynamoItemWithProcessorCustomer = new HashMap<>(dynamoItem);