From 6279149cb80b09efc757e8c68ad44e065a5cd472 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 3 Oct 2023 09:57:34 -0400 Subject: [PATCH] Add SEPA API endpoints. --- .../DonationsConfigurationExtensions.kt | 7 +++++ .../app/subscription/InAppDonations.kt | 11 ++++++- .../app/subscription/StripeRepository.kt | 25 +++++++++------- .../StripePaymentInProgressViewModel.kt | 5 +++- .../errors/DonationErrorParams.kt | 29 +++++++++++++++++++ .../securesms/util/FeatureFlags.java | 11 ++++++- .../DonationsConfigurationExtensionsKtTest.kt | 3 ++ .../org/signal/donations/PaymentSourceType.kt | 11 ++++--- .../java/org/signal/donations/StripeApi.kt | 15 ++++++---- .../api/services/DonationsService.java | 8 ++--- .../internal/push/BankMandate.kt | 14 +++++++++ .../internal/push/PushServiceSocket.java | 25 ++++++++++++---- .../StripeOneTimePaymentIntentPayload.java | 12 +++++--- 13 files changed, 140 insertions(+), 36 deletions(-) create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt index 1f3b3ce90d..85aa56820a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt @@ -14,6 +14,7 @@ import java.util.Currency private const val CARD = "CARD" private const val PAYPAL = "PAYPAL" +private const val SEPA_DEBIT = "SEPA_DEBIT" /** * Transforms the DonationsConfiguration into a Set which has been properly filtered @@ -116,6 +117,7 @@ private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailabili interface PaymentMethodAvailability { fun isPayPalAvailable(): Boolean fun isGooglePayOrCreditCardAvailable(): Boolean + fun isSEPADebitAvailable(): Boolean fun toSet(): Set { val set = mutableSetOf() @@ -127,6 +129,10 @@ interface PaymentMethodAvailability { set.add(CARD) } + if (isSEPADebitAvailable()) { + set.add(SEPA_DEBIT) + } + return set } } @@ -134,4 +140,5 @@ interface PaymentMethodAvailability { private object DefaultPaymentMethodAvailability : PaymentMethodAvailability { override fun isPayPalAvailable(): Boolean = InAppDonations.isPayPalAvailable() override fun isGooglePayOrCreditCardAvailable(): Boolean = InAppDonations.isCreditCardAvailable() || InAppDonations.isGooglePayAvailable() + override fun isSEPADebitAvailable(): Boolean = InAppDonations.isSEPADebitAvailable() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index 9e138cff9c..ad19d0cecc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -16,10 +16,11 @@ object InAppDonations { * * - Able to use Credit Cards and is in a region where they are able to be accepted. * - Able to access Google Play services (and thus possibly able to use Google Pay). + * - Able to use SEPA Debit and is in a region where they are able to be accepted. * - Able to use PayPal and is in a region where it is able to be accepted. */ fun hasAtLeastOnePaymentMethodAvailable(): Boolean { - return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() + return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() } fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean { @@ -27,6 +28,7 @@ object InAppDonations { PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType) PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable() PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable() + PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailable() PaymentSourceType.Unknown -> false } } @@ -58,4 +60,11 @@ object InAppDonations { fun isGooglePayAvailable(): Boolean { return SignalStore.donationsValues().isGooglePayReady && !LocaleFeatureFlags.isGooglePayDisabled() } + + /** + * Whether the user is in a region which supports SEPA Debit transfers, based off local phone number. + */ + fun isSEPADebitAvailable(): Boolean { + return FeatureFlags.sepaDebitDonations() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index e33337bb28..c0a9558fb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -90,9 +90,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str badgeLevel: Long, paymentSourceType: PaymentSourceType ): Single { + check(paymentSourceType is PaymentSourceType.Stripe) + Log.d(TAG, "Creating payment intent for $price...", true) - return stripeApi.createPaymentIntent(price, badgeLevel) + return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType) .onErrorResumeNext { OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType) } @@ -110,9 +112,12 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str }.subscribeOn(Schedulers.io()) } - fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single { + fun createAndConfirmSetupIntent( + paymentSource: StripeApi.PaymentSource, + paymentSourceType: PaymentSourceType.Stripe + ): Single { Log.d(TAG, "Continuing subscription setup...", true) - return stripeApi.createSetupIntent() + return stripeApi.createSetupIntent(paymentSourceType) .flatMap { result -> Log.d(TAG, "Retrieved SetupIntent, confirming...", true) stripeApi.confirmSetupIntent(paymentSource, result.setupIntent) @@ -134,13 +139,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str } } - override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single { + override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})") return Single .fromCallable { ApplicationDependencies .getDonationsService() - .createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level) + .createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod) } .flatMap(ServiceResponse::flattenResult) .map { @@ -159,27 +164,27 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str * it means that the PaymentMethod is already tied to a PayPal account. We can retry in this * situation by simply deleting the old subscriber id on the service and replacing it. */ - private fun createPaymentMethod(retryOn409: Boolean = true): Single { + private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single { return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() } .flatMap { Single.fromCallable { ApplicationDependencies .getDonationsService() - .createStripeSubscriptionPaymentMethod(it.subscriberId) + .createStripeSubscriptionPaymentMethod(it.subscriberId, paymentSourceType.paymentMethod) } } .flatMap { serviceResponse -> if (retryOn409 && serviceResponse.status == 409) { - monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false)) + monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(paymentSourceType, retryOn409 = false)) } else { serviceResponse.flattenResult() } } } - override fun fetchSetupIntent(): Single { + override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single { Log.d(TAG, "Fetching setup intent from Signal service...") - return createPaymentMethod() + return createPaymentMethod(sourceType) .map { StripeIntentAccessor( objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index c4a0df24fe..4c06c6186f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -128,7 +128,10 @@ class StripePaymentInProgressViewModel( private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single) { val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId() - val createAndConfirmSetupIntent: Single = paymentSourceProvider.paymentSource.flatMap { stripeRepository.createAndConfirmSetupIntent(it) } + val createAndConfirmSetupIntent: Single = paymentSourceProvider.paymentSource.flatMap { + stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe) + } + val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString()) Log.d(TAG, "Starting subscription payment pipeline...", true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt index c64bd97f82..33f0bed5f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -33,18 +33,21 @@ class DonationErrorParams private constructor( positiveAction = callback.onOk(context), negativeAction = null ) + is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams( title = R.string.DonationsErrors__still_processing, message = R.string.DonationsErrors__your_payment_is_still, positiveAction = callback.onOk(context), negativeAction = null ) + is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams( title = R.string.DonationsErrors__failed_to_validate_badge, message = R.string.DonationsErrors__could_not_validate, positiveAction = callback.onContactSupport(context), negativeAction = null ) + is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback) else -> DonationErrorParams( title = R.string.DonationsErrors__couldnt_add_badge, @@ -63,6 +66,7 @@ class DonationErrorParams private constructor( positiveAction = callback.onContactSupport(context), negativeAction = null ) + else -> DonationErrorParams( title = R.string.DonationsErrors__couldnt_add_badge, message = R.string.DonationsErrors__your_badge_could_not, @@ -80,6 +84,7 @@ class DonationErrorParams private constructor( positiveAction = callback.onOk(context), negativeAction = null ) + else -> DonationErrorParams( title = R.string.DonationsErrors__cannot_send_donation, message = R.string.DonationsErrors__this_user_cant_receive_donations_until, @@ -97,9 +102,14 @@ class DonationErrorParams private constructor( } private fun getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback): DonationErrorParams { + fun unexpectedDeclinedError(declinedError: DonationError.PaymentSetupError.StripeDeclinedError): Nothing { + error("Unexpected declined error: ${declinedError.declineCode} during ${declinedError.method} processing.") + } + val getStripeDeclineCodePositiveActionParams: (Context, Callback, Int) -> DonationErrorParams = when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> this::getTryCreditCardAgainParams PaymentSourceType.Stripe.GooglePay -> this::getGoToGooglePayParams + PaymentSourceType.Stripe.SEPADebit -> error("Not implemented.") } return when (declinedError.declineCode) { @@ -110,16 +120,20 @@ class DonationErrorParams private constructor( when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase) StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams( context, @@ -127,24 +141,30 @@ class DonationErrorParams private constructor( when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds) StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams( context, @@ -152,37 +172,46 @@ class DonationErrorParams private constructor( when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams( context, callback, when (declinedError.method) { PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect + PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError) } ) + StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again) StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again) StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again) else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank) } + else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 1cfb37f991..6df76f3ef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -117,6 +117,7 @@ public final class FeatureFlags { public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback"; private static final String CONVERSATION_ITEM_V2_TEXT = "android.conversationItemV2.text.4"; public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig"; + private static final String SEPA_DEBIT_DONATIONS = "android.sepa.debit.donations"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -190,7 +191,8 @@ public final class FeatureFlags { @VisibleForTesting static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( - PHONE_NUMBER_PRIVACY + PHONE_NUMBER_PRIVACY, + SEPA_DEBIT_DONATIONS ); /** @@ -680,6 +682,13 @@ public final class FeatureFlags { return getString(CRASH_PROMPT_CONFIG, ""); } + /** + * Whether or not SEPA debit payments for donations are enabled. + * WARNING: This feature is under heavy development and is *not* ready for wider use. + */ + public static boolean sepaDebitDonations() { + return getBoolean(SEPA_DEBIT_DONATIONS, Environment.IS_STAGING); + } /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt index 1c73e418b2..01f2cfa9f7 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt @@ -212,15 +212,18 @@ class DonationsConfigurationExtensionsKtTest { private object AllPaymentMethodsAvailability : PaymentMethodAvailability { override fun isPayPalAvailable(): Boolean = true override fun isGooglePayOrCreditCardAvailable(): Boolean = true + override fun isSEPADebitAvailable(): Boolean = false } private object PayPalOnly : PaymentMethodAvailability { override fun isPayPalAvailable(): Boolean = true override fun isGooglePayOrCreditCardAvailable(): Boolean = false + override fun isSEPADebitAvailable(): Boolean = false } private object CardOnly : PaymentMethodAvailability { override fun isPayPalAvailable(): Boolean = false override fun isGooglePayOrCreditCardAvailable(): Boolean = true + override fun isSEPADebitAvailable(): Boolean = false } } diff --git a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt index 7114e51d9d..1f5efa05ef 100644 --- a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt +++ b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt @@ -11,16 +11,18 @@ sealed class PaymentSourceType { override val code: String = Codes.PAY_PAL.code } - sealed class Stripe(override val code: String) : PaymentSourceType() { - object CreditCard : Stripe(Codes.CREDIT_CARD.code) - object GooglePay : Stripe(Codes.GOOGLE_PAY.code) + sealed class Stripe(override val code: String, val paymentMethod: String) : PaymentSourceType() { + object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD") + object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD") + object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT") } private enum class Codes(val code: String) { UNKNOWN("unknown"), PAY_PAL("paypal"), CREDIT_CARD("credit_card"), - GOOGLE_PAY("google_pay") + GOOGLE_PAY("google_pay"), + SEPA_DEBIT("sepa_debit") } companion object { @@ -30,6 +32,7 @@ sealed class PaymentSourceType { Codes.PAY_PAL -> PayPal Codes.CREDIT_CARD -> Stripe.CreditCard Codes.GOOGLE_PAY -> Stripe.GooglePay + Codes.SEPA_DEBIT -> Stripe.SEPADebit } } } diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 887c4bb290..a881f00e12 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -59,9 +59,9 @@ class StripeApi( data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult() } - fun createSetupIntent(): Single { + fun createSetupIntent(sourceType: PaymentSourceType.Stripe): Single { return setupIntentHelper - .fetchSetupIntent() + .fetchSetupIntent(sourceType) .map { CreateSetupIntentResult(it) } .subscribeOn(Schedulers.io()) } @@ -84,7 +84,7 @@ class StripeApi( } } - fun createPaymentIntent(price: FiatMoney, level: Long): Single { + fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { @Suppress("CascadeIf") return if (Validation.isAmountTooSmall(price)) { Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price)) @@ -95,7 +95,7 @@ class StripeApi( Single.just(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)) } else { paymentIntentFetcher - .fetchPaymentIntent(price, level) + .fetchPaymentIntent(price, level, sourceType) .map { CreatePaymentIntentResult.Success(it) } }.subscribeOn(Schedulers.io()) } @@ -513,12 +513,15 @@ class StripeApi( interface PaymentIntentFetcher { fun fetchPaymentIntent( price: FiatMoney, - level: Long + level: Long, + sourceType: PaymentSourceType.Stripe ): Single } interface SetupIntentHelper { - fun fetchSetupIntent(): Single + fun fetchSetupIntent( + sourceType: PaymentSourceType.Stripe + ): Single } @Parcelize diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 5f79508827..10f22a519d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -91,8 +91,8 @@ public class DonationsService { * @param currencyCode The currency code for the amount * @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway. */ - public ServiceResponse createDonationIntentWithAmount(String amount, String currencyCode, long level) { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.createStripeOneTimePaymentIntent(currencyCode, Long.parseLong(amount), level), 200)); + public ServiceResponse createDonationIntentWithAmount(String amount, String currencyCode, long level, String paymentMethod) { + return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.createStripeOneTimePaymentIntent(currencyCode, paymentMethod, Long.parseLong(amount), level), 200)); } /** @@ -205,9 +205,9 @@ public class DonationsService { * @return Client secret for a SetupIntent. It should not be used with the PaymentIntent stripe APIs * but instead with the SetupIntent stripe APIs. */ - public ServiceResponse createStripeSubscriptionPaymentMethod(SubscriberId subscriberId) { + public ServiceResponse createStripeSubscriptionPaymentMethod(SubscriberId subscriberId, String type) { return wrapInServiceResponse(() -> { - StripeClientSecret clientSecret = pushServiceSocket.createStripeSubscriptionPaymentMethod(subscriberId.serialize()); + StripeClientSecret clientSecret = pushServiceSocket.createStripeSubscriptionPaymentMethod(subscriberId.serialize(), type); return new Pair<>(clientSecret, 200); }); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt new file mode 100644 index 0000000000..9a9dbfc589 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Localized bank transfer mandate. + */ +class BankMandate @JsonCreator constructor(@JsonProperty("mandate") mandate: String) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 3bc7eac697..36bce5b32a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -277,7 +277,7 @@ public class PushServiceSocket { private static final String UPDATE_SUBSCRIPTION_LEVEL = "/v1/subscription/%s/level/%s/%s/%s"; private static final String SUBSCRIPTION = "/v1/subscription/%s"; - private static final String CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method"; + private static final String CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method?type=%s"; private static final String CREATE_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method/paypal"; private static final String DEFAULT_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/stripe/%s"; private static final String DEFAULT_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/braintree/%s"; @@ -287,6 +287,7 @@ public class PushServiceSocket { private static final String CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/paypal/confirm"; private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials"; private static final String DONATIONS_CONFIGURATION = "/v1/subscription/configuration"; + private static final String BANK_MANDATE = "/v1/subscription/bank_mandate/%s"; private static final String VERIFICATION_SESSION_PATH = "/v1/verification/session"; private static final String VERIFICATION_CODE_PATH = "/v1/verification/session/%s/code"; @@ -1139,8 +1140,8 @@ public class PushServiceSocket { makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload); } - public StripeClientSecret createStripeOneTimePaymentIntent(String currencyCode, long amount, long level) throws IOException { - String payload = JsonUtil.toJson(new StripeOneTimePaymentIntentPayload(amount, currencyCode, level)); + public StripeClientSecret createStripeOneTimePaymentIntent(String currencyCode, String paymentMethod, long amount, long level) throws IOException { + String payload = JsonUtil.toJson(new StripeOneTimePaymentIntentPayload(amount, currencyCode, level, paymentMethod)); String result = makeServiceRequestWithoutAuthentication(CREATE_STRIPE_ONE_TIME_PAYMENT_INTENT, "POST", payload); return JsonUtil.fromJsonResponse(result, StripeClientSecret.class); } @@ -1196,6 +1197,17 @@ public class PushServiceSocket { return JsonUtil.fromJson(result, DonationsConfiguration.class); } + /** + * @param bankTransferType Valid values for bankTransferType are {SEPA_DEBIT}. + * @return localized bank mandate text for the given bankTransferType. + */ + public BankMandate getBankMandate(Locale locale, String bankTransferType) throws IOException { + Map headers = Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()); + String result = makeServiceRequestWithoutAuthentication(String.format(BANK_MANDATE, bankTransferType), "GET", null, headers, NO_HANDLER); + + return JsonUtil.fromJson(result, BankMandate.class); + } + public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException { makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", ""); } @@ -1213,8 +1225,11 @@ public class PushServiceSocket { makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "DELETE", null); } - public StripeClientSecret createStripeSubscriptionPaymentMethod(String subscriberId) throws IOException { - String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", ""); + /** + * @param type One of CARD or SEPA_DEBIT + */ + public StripeClientSecret createStripeSubscriptionPaymentMethod(String subscriberId, String type) throws IOException { + String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId, type), "POST", ""); return JsonUtil.fromJson(response, StripeClientSecret.class); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StripeOneTimePaymentIntentPayload.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StripeOneTimePaymentIntentPayload.java index 3638901a94..f36bac40dd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StripeOneTimePaymentIntentPayload.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/StripeOneTimePaymentIntentPayload.java @@ -12,9 +12,13 @@ class StripeOneTimePaymentIntentPayload { @JsonProperty private long level; - public StripeOneTimePaymentIntentPayload(long amount, String currency, long level) { - this.amount = amount; - this.currency = currency; - this.level = level; + @JsonProperty + private String paymentMethod; + + public StripeOneTimePaymentIntentPayload(long amount, String currency, long level, String paymentMethod) { + this.amount = amount; + this.currency = currency; + this.level = level; + this.paymentMethod = paymentMethod; } }