Add SEPA API endpoints.

This commit is contained in:
Alex Hart
2023-10-03 09:57:34 -04:00
committed by Cody Henthorne
parent f5c5a34798
commit 6279149cb8
13 changed files with 140 additions and 36 deletions

View File

@@ -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<FiatMoney> 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<String> {
val set = mutableSetOf<String>()
@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -90,9 +90,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
badgeLevel: Long,
paymentSourceType: PaymentSourceType
): Single<StripeIntentAccessor> {
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<StripeApi.Secure3DSAction> {
fun createAndConfirmSetupIntent(
paymentSource: StripeApi.PaymentSource,
paymentSourceType: PaymentSourceType.Stripe
): Single<StripeApi.Secure3DSAction> {
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<StripeIntentAccessor> {
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
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<StripeClientSecret>::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<StripeClientSecret> {
private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
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<StripeIntentAccessor> {
override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching setup intent from Signal service...")
return createPaymentMethod()
return createPaymentMethod(sourceType)
.map {
StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,

View File

@@ -128,7 +128,10 @@ class StripePaymentInProgressViewModel(
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap { stripeRepository.createAndConfirmSetupIntent(it) }
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = 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)

View File

@@ -33,18 +33,21 @@ class DonationErrorParams<V> 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<V> 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<V> 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<V> private constructor(
}
private fun <V> getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
fun unexpectedDeclinedError(declinedError: DonationError.PaymentSetupError.StripeDeclinedError): Nothing {
error("Unexpected declined error: ${declinedError.declineCode} during ${declinedError.method} processing.")
}
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = 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<V> 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<V> 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<V> 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)
}
}

View File

@@ -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<String> 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<String, Object> getMemoryValues() {