diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index f82ed9c90..52a87fe98 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -257,6 +257,7 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient; import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; +import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider; @@ -419,6 +420,8 @@ public class WhisperServerService extends Application { - final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream() + final List acceptableLanguages = + HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext); + + // These two localizations are a best-effort, and it's possible that the first `locale` and the localized line + // item name will not match. We could try to align with the locales PayPal documents + // but that's a moving target, and we can hopefully have one of them be better for the user by selecting + // independently. + final Locale locale = acceptableLanguages.stream() .filter(l -> !"*".equals(l.getLanguage())) .findFirst() .orElse(Locale.US); + final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages, + PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY); return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount, locale.toLanguageTag(), - request.returnUrl, request.cancelUrl); + request.returnUrl, request.cancelUrl, localizedLineItemName); }) .thenApply(approvalDetails -> Response.ok( new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build()); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java index 5f6459f76..8ddddf339 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java @@ -21,12 +21,14 @@ import com.braintree.graphql.client.type.PayPalBillingAgreementInput; import com.braintree.graphql.client.type.PayPalExperienceProfileInput; import com.braintree.graphql.client.type.PayPalIntent; import com.braintree.graphql.client.type.PayPalLandingPageType; +import com.braintree.graphql.client.type.PayPalLineItemInput; import com.braintree.graphql.client.type.PayPalOneTimePaymentInput; import com.braintree.graphql.client.type.PayPalProductAttributesInput; import com.braintree.graphql.client.type.PayPalUserAction; import com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput; import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput; import com.braintree.graphql.client.type.TransactionInput; +import com.braintree.graphql.client.type.TransactionLineItemType; import com.braintree.graphql.client.type.VaultPaymentMethodInput; import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation; import com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation; @@ -77,10 +79,10 @@ class BraintreeGraphqlClient { CompletableFuture createPayPalOneTimePayment( final BigDecimal amount, final String currency, final String returnUrl, - final String cancelUrl, final String locale) { + final String cancelUrl, final String locale, final String localizedLineItemName) { final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl, - cancelUrl, locale); + cancelUrl, locale, localizedLineItemName); final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(input); final HttpRequest request = buildRequest(mutation); @@ -104,7 +106,12 @@ class BraintreeGraphqlClient { } private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount, - String currency, String returnUrl, String cancelUrl, String locale) { + String currency, String returnUrl, String cancelUrl, String locale, String localizedLineItemName) { + + // Note locale and localizedLineItemName are a best-effort, and it's possible that they will not match. + // We could try to align with the locales PayPal documents + // but that's a moving target, and we can hopefully have one of them be better for the user by selecting + // independently. return new CreatePayPalOneTimePaymentInput( Optional.absent(), @@ -113,7 +120,17 @@ class BraintreeGraphqlClient { cancelUrl, Optional.absent(), PayPalIntent.SALE, - Optional.absent(), + Optional.present(List.of( + new PayPalLineItemInput( + localizedLineItemName, + 1, // quantity, always 1 + amount.toString(), + TransactionLineItemType.DEBIT, + Optional.absent(), + Optional.absent(), + 0, // unitTaxAmount, always zero + Optional.absent() + ))), Optional.present(false), // offerPayLater, Optional.absent(), Optional.present( 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 094f807db..527f850ac 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java @@ -149,10 +149,10 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess } public CompletableFuture createOneTimePayment(String currency, long amount, - String locale, String returnUrl, String cancelUrl) { + String locale, String returnUrl, String cancelUrl, String localizedLineItemname) { return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount), currency.toUpperCase(Locale.ROOT), returnUrl, - cancelUrl, locale) + cancelUrl, locale, localizedLineItemname) .thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId)); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslator.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslator.java new file mode 100644 index 000000000..3017c1738 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslator.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.ResourceBundle; +import javax.annotation.Nonnull; +import org.signal.i18n.HeaderControlledResourceBundleLookup; + +public class PayPalDonationsTranslator { + + public static final String ONE_TIME_DONATION_LINE_ITEM_KEY = "oneTime.donationLineItemName"; + + private static final String BASE_NAME = "org.signal.donations.PayPal"; + + private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup; + + public PayPalDonationsTranslator( + @Nonnull final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) { + this.headerControlledResourceBundleLookup = Objects.requireNonNull(headerControlledResourceBundleLookup); + } + + public String translate(final List acceptableLanguages, final String key) { + final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME, + acceptableLanguages); + return resourceBundle.getString(key); + } +} diff --git a/service/src/main/resources/org/signal/donations/PayPal.properties b/service/src/main/resources/org/signal/donations/PayPal.properties new file mode 100644 index 000000000..6c5c968df --- /dev/null +++ b/service/src/main/resources/org/signal/donations/PayPal.properties @@ -0,0 +1,7 @@ +# +# Copyright 2026 Signal Messenger, LLC +# SPDX-License-Identifier: AGPL-3.0-only +# + +# checkout line item description on the donation confirmation web page. Max length: 127 characters +oneTime.donationLineItemName = Donation to Signal Technology Foundation 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 a9294283d..bcf103b6e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -94,6 +94,7 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus; import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; +import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; @@ -132,6 +133,8 @@ class SubscriptionControllerTest { private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); + private static final PayPalDonationsTranslator PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR = mock( + PayPalDonationsTranslator.class); private static final DynamicConfigurationManager DYNAMIC_CONFIGURATION_MANAGER = mock(DynamicConfigurationManager.class); private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, @@ -139,7 +142,8 @@ class SubscriptionControllerTest { ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER, BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DYNAMIC_CONFIGURATION_MANAGER); private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK, - ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER); + ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR, + ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER); private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProvider(AuthHelper.getAuthFilter()) @@ -154,10 +158,12 @@ class SubscriptionControllerTest { @BeforeEach void setUp() { - reset(CLOCK, SUBSCRIPTIONS, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR); + reset(CLOCK, SUBSCRIPTIONS, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, + PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR); when(STRIPE_MANAGER.getProvider()).thenReturn(PaymentProvider.STRIPE); when(BRAINTREE_MANAGER.getProvider()).thenReturn(PaymentProvider.BRAINTREE); + when(PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR.translate(any(), any())).thenReturn("Donation to Signal Technology Foundation"); final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); when(dynamicConfiguration.getBackupConfiguration()) .thenReturn(new DynamicBackupConfiguration(null, null, null, null, MAX_TOTAL_BACKUP_MEDIA_BYTES)); @@ -277,7 +283,7 @@ class SubscriptionControllerTest { final PayPalOneTimePaymentApprovalDetails payPalOneTimePaymentApprovalDetails = mock(PayPalOneTimePaymentApprovalDetails.class); when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL)) .thenReturn(Set.of("usd", "jpy", "bif", "eur")); - when(BRAINTREE_MANAGER.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString())) + when(BRAINTREE_MANAGER.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString())) .thenReturn(CompletableFuture.completedFuture(payPalOneTimePaymentApprovalDetails)); when(payPalOneTimePaymentApprovalDetails.approvalUrl()).thenReturn("approvalUrl"); when(payPalOneTimePaymentApprovalDetails.paymentId()).thenReturn("someId"); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java index 693c2f750..c5f3e5b0a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java @@ -34,6 +34,7 @@ class BraintreeGraphqlClientTest { private static final String RETURN_URL = "https://example.com/return"; private static final String CANCEL_URL = "https://example.com/cancel"; private static final String LOCALE = "xx"; + private static final String LOCALIZED_LINE_ITEM_NAME = "Donation to Signal Technology Foundation"; private FaultTolerantHttpClient httpClient; private BraintreeGraphqlClient braintreeGraphqlClient; @@ -61,7 +62,7 @@ class BraintreeGraphqlClientTest { final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); + RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME); assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { final CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment result = future.get(); @@ -87,7 +88,7 @@ class BraintreeGraphqlClientTest { final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); + RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME); assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { @@ -111,7 +112,7 @@ class BraintreeGraphqlClientTest { final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); + RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME); assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslatorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslatorTest.java new file mode 100644 index 000000000..eaa317ca3 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslatorTest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import org.junit.jupiter.api.Test; +import org.signal.i18n.HeaderControlledResourceBundleLookup; + +class PayPalDonationsTranslatorTest { + + private final PayPalDonationsTranslator translator = new PayPalDonationsTranslator( + new HeaderControlledResourceBundleLookup()); + + @Test + void testTranslate() { + assertEquals("Donation to Signal Technology Foundation", + translator.translate(List.of(Locale.ROOT), PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY)); + } + + @Test + void testTranslateUnknownKey() { + assertThrows(MissingResourceException.class, () -> translator.translate(List.of(Locale.ROOT), "unknown-key")); + } + +}