Implement Stripe Failure Code support.

This commit is contained in:
Alex Hart
2023-10-18 09:16:50 -04:00
committed by Cody Henthorne
parent 9da5f47623
commit 280da481ee
14 changed files with 219 additions and 23 deletions
@@ -4,6 +4,7 @@ import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
@@ -38,6 +39,7 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
val badge: Badge = args.badge
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
@@ -69,6 +71,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
getString(declineCode.mapToErrorStringResource()),
badge.name
)
} else if (failureCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(failureCode.mapToErrorStringResource()),
badge.name
)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
} else {
@@ -284,20 +284,30 @@ class DonationCheckoutDelegate(
fragment!!.requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
var tryCCAgain = false
var tryAgain = false
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryCCAgain = true
tryAgain = true
}
)
}
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onDialogDismissed() {
errorDialog = null
if (!tryCCAgain) {
if (!tryAgain) {
tryAgain = false
fragment!!.findNavController().popBackStack()
}
}
@@ -8,6 +8,7 @@ import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeError
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
@@ -64,6 +65,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
*/
class StripeDeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode, val method: PaymentSourceType.Stripe) : PaymentSetupError(source, cause)
/**
* Bank Transfer failed, with a specific reason told to us by Stripe
*/
class StripeFailureCodeError(source: DonationErrorSource, cause: Throwable, val failureCode: StripeFailureCode, val method: PaymentSourceType.Stripe) : PaymentSetupError(source, cause)
/**
* Payment setup failed in some way, which we are told about by PayPal.
*/
@@ -186,16 +192,16 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
@JvmStatic
fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable, method: PaymentSourceType): DonationError {
return when (throwable) {
is StripeError.PostError -> {
val declineCode: StripeDeclineCode? = throwable.declineCode
is StripeError.PostError.Generic -> {
val errorCode: String? = throwable.errorCode
when {
declineCode != null && method is PaymentSourceType.Stripe -> PaymentSetupError.StripeDeclinedError(source, throwable, declineCode, method)
errorCode != null && method is PaymentSourceType.Stripe -> PaymentSetupError.StripeCodedError(source, throwable, errorCode)
else -> PaymentSetupError.GenericError(source, throwable)
if (errorCode != null) {
PaymentSetupError.StripeCodedError(source, throwable, errorCode)
} else {
PaymentSetupError.GenericError(source, throwable)
}
}
is StripeError.PostError.Declined -> PaymentSetupError.StripeDeclinedError(source, throwable, throwable.declineCode, method as PaymentSourceType.Stripe)
is StripeError.PostError.Failed -> PaymentSetupError.StripeFailureCodeError(source, throwable, throwable.failureCode, method as PaymentSourceType.Stripe)
is UserCancelledPaymentError -> {
return throwable
@@ -68,6 +68,13 @@ object DonationErrorDialogs {
)
}
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {}
)
}
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__learn_more,
@@ -64,6 +64,7 @@ object DonationErrorNotifications {
}
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
return createAction(
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.StringRes
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
class DonationErrorParams<V> private constructor(
@@ -26,15 +27,10 @@ class DonationErrorParams<V> private constructor(
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError -> DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = R.string.DonationsErrors__your_payment,
positiveAction = callback.onOk(context),
negativeAction = null
)
is DonationError.PaymentSetupError -> getGenericPaymentSetupErrorParams(context, callback)
// TODO [sepa] -- This is only used for the notification, and will be rare, but we should probably have better copy here.
is DonationError.BadgeRedemptionError.DonationPending -> DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
@@ -110,6 +106,10 @@ class DonationErrorParams<V> private constructor(
}
private fun <V> getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
if (!declinedError.method.hasDeclineCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
}
fun unexpectedDeclinedError(declinedError: DonationError.PaymentSetupError.StripeDeclinedError): Nothing {
error("Unexpected declined error: ${declinedError.declineCode} during ${declinedError.method} processing.")
}
@@ -224,6 +224,43 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getStripeFailureCodeErrorParams(context: Context, failureCodeError: DonationError.PaymentSetupError.StripeFailureCodeError, callback: Callback<V>): DonationErrorParams<V> {
if (!failureCodeError.method.hasFailureCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
}
return when (failureCodeError.failureCode) {
is StripeFailureCode.Known -> {
val errorText = failureCodeError.failureCode.mapToErrorStringResource()
when (failureCodeError.failureCode.code) {
StripeFailureCode.Code.REFER_TO_CUSTOMER -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_DISPUTED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.ACCOUNT_CLOSED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.BANK_ACCOUNT_RESTRICTED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.RECIPIENT_DECEASED -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.BRANCH_DOES_NOT_EXIST -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INCORRECT_ACCOUNT_HOLDER_NAME -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INVALID_ACCOUNT_NUMBER -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, errorText)
}
}
is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback)
}
}
private fun <V> getGenericPaymentSetupErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = R.string.DonationsErrors__your_payment,
positiveAction = callback.onOk(context),
negativeAction = null
)
}
private fun <V> getLearnMoreParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
@@ -250,6 +287,15 @@ class DonationErrorParams<V> private constructor(
negativeAction = callback.onCancel(context)
)
}
private fun <V> getTryBankTransferAgainParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = message,
positiveAction = callback.onTryBankTransferAgain(context),
negativeAction = callback.onCancel(context)
)
}
}
interface Callback<V> {
@@ -259,5 +305,6 @@ class DonationErrorParams<V> private constructor(
fun onContactSupport(context: Context): ErrorAction<V>?
fun onGoToGooglePay(context: Context): ErrorAction<V>?
fun onTryCreditCardAgain(context: Context): ErrorAction<V>?
fun onTryBankTransferAgain(context: Context): ErrorAction<V>?
}
}
@@ -2,8 +2,31 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import androidx.annotation.StringRes
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
@StringRes
fun StripeFailureCode.mapToErrorStringResource(): Int {
return when (this) {
is StripeFailureCode.Known -> when (this.code) {
StripeFailureCode.Code.REFER_TO_CUSTOMER -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> R.string.StripeFailureCode__the_bank_account_provided
StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct // TODO [sepa] -- verify
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> R.string.StripeFailureCode__this_payment_was_revoked
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> R.string.StripeFailureCode__this_payment_was_revoked
StripeFailureCode.Code.ACCOUNT_CLOSED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.BANK_ACCOUNT_RESTRICTED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.DEBIT_AUTHORIZATION_NOT_MATCH -> R.string.StripeFailureCode__an_error_occurred_while_processing_this_payment
StripeFailureCode.Code.RECIPIENT_DECEASED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
StripeFailureCode.Code.BRANCH_DOES_NOT_EXIST -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.INCORRECT_ACCOUNT_HOLDER_NAME -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.INVALID_ACCOUNT_NUMBER -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
}
is StripeFailureCode.Unknown -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
}
}
@StringRes
fun StripeDeclineCode.mapToErrorStringResource(): Int {
return when (this) {
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
@@ -18,8 +19,11 @@ fun DonationProcessorError.toDonationError(
ActiveSubscription.Processor.STRIPE -> {
check(method is PaymentSourceType.Stripe)
val declineCode = StripeDeclineCode.getFromCode(chargeFailure.code)
val failureCode = StripeFailureCode.getFromCode(chargeFailure.code)
if (declineCode.isKnown()) {
DonationError.PaymentSetupError.StripeDeclinedError(source, this, declineCode, method)
} else if (failureCode.isKnown) {
DonationError.PaymentSetupError.StripeFailureCodeError(source, this, failureCode, method)
} else if (chargeFailure.code != null) {
DonationError.PaymentSetupError.StripeCodedError(source, this, chargeFailure.code)
} else {
@@ -7,6 +7,7 @@ import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.signal.donations.PaymentSourceType;
import org.signal.donations.StripeDeclineCode;
import org.signal.donations.StripeFailureCode;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
@@ -308,6 +309,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
Log.d(TAG, "Stripe charge failure detected: " + chargeFailure, true);
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
StripeFailureCode failureCode = StripeFailureCode.Companion.getFromCode(chargeFailure.getCode());
DonationError.PaymentSetupError paymentSetupError;
PaymentSourceType paymentSourceType = SignalStore.donationsValues().getSubscriptionPaymentSourceType();
boolean isStripeSource = paymentSourceType instanceof PaymentSourceType.Stripe;
@@ -319,6 +321,13 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
declineCode,
(PaymentSourceType.Stripe) paymentSourceType
);
} else if (failureCode.isKnown() && isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeFailureCodeError(
getErrorSource(),
new Exception(chargeFailure.getMessage()),
failureCode,
(PaymentSourceType.Stripe) paymentSourceType
);
} else if (isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeCodedError(
getErrorSource(),
+12
View File
@@ -4917,6 +4917,18 @@
<!-- Stripe decline code incorrect_number and invalid_number for credit cards displayed in a notification or dialog -->
<string name="DeclineCode__your_card_number_is_incorrect_verify_your_card_details">Your card number is incorrect. Verify your card details are correct and try again.</string>
<!-- Stripe Failure Codes for failed bank transfers -->
<!-- Failure code text for insufficient funds, displayed in a dialog or notification -->
<string name="StripeFailureCode__the_bank_account_provided">The bank account provided has insufficient funds to complete this purchase, try again or contact your bank for more information.</string>
<!-- Failure code text for revoked authorization of payment, displayed in a dialog or notification -->
<string name="StripeFailureCode__this_payment_was_revoked">This payment was revoked by the account holder and could not be processed. You haven\'t been charged.</string>
<!-- Failure code text for a payment lacking an authorized mandate or incorrect mandate, displayed in a dialog or notification -->
<string name="StripeFailureCode__an_error_occurred_while_processing_this_payment">An error occurred while processing this payment, please try again.</string>
<!-- Failure code text for a closed account, deceased recipient, or one with blocked direct debits, displayed in a dialog or notification -->
<string name="StripeFailureCode__the_bank_details_provided_could_not_be_processed">The bank details provided could not be processed, contact your bank for more information.</string>
<!-- Failure code text for a non-existent bank branch, invalid account holder, invalid iban, generic failure, or unknown bank failure, displayed in a dialog or notification -->
<string name="StripeFailureCode__verify_your_bank_details_are_correct">Verify your bank details are correct and try again. If the problem continues, contact your bank.</string>
<!-- Title of create notification profile screen -->
<string name="EditNotificationProfileFragment__name_your_profile">Name your profile</string>
<!-- Hint text for create/edit notification profile name -->
@@ -16,6 +16,9 @@ sealed class PaymentSourceType {
object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
fun hasDeclineCodeSupport(): Boolean = this !is SEPADebit
fun hasFailureCodeSupport(): Boolean = this is SEPADebit
}
private enum class Codes(val code: String) {
@@ -305,12 +305,15 @@ class StripeApi(
val body = response.body()?.string()
val errorCode = parseErrorCode(body)
val declineCode = parseDeclineCode(body) ?: StripeDeclineCode.getFromCode(errorCode)
val failureCode = parseFailureCode(body) ?: StripeFailureCode.getFromCode(errorCode)
throw StripeError.PostError(
response.code(),
errorCode,
declineCode
)
if (failureCode is StripeFailureCode.Known) {
throw StripeError.PostError.Failed(response.code(), failureCode)
} else if (declineCode is StripeDeclineCode.Known) {
throw StripeError.PostError.Declined(response.code(), declineCode)
} else {
throw StripeError.PostError.Generic(response.code(), errorCode)
}
}
}
@@ -342,6 +345,20 @@ class StripeApi(
}
}
private fun parseFailureCode(body: String?): StripeFailureCode? {
if (body == null) {
Log.d(TAG, "parseFailureCode: No body.", true)
return null
}
return try {
StripeFailureCode.getFromCode(JSONObject(body).getJSONObject("error").getString("failure_code"))
} catch (e: Exception) {
Log.d(TAG, "parseFailureCode: Failed to parse failure code.", e, true)
null
}
}
object Validation {
private val MAX_AMOUNT = BigDecimal(99_999_999)
@@ -7,5 +7,11 @@ sealed class StripeError(message: String) : Exception(message) {
class FailedToParseSetupIntentResponseError(invalidDefCause: InvalidDefinitionException?) : StripeError("Failed to parse setup intent response: ${invalidDefCause?.type} ${invalidDefCause?.property} ${invalidDefCause?.beanDescription}")
object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response")
object FailedToCreatePaymentSourceFromCardData : StripeError("Failed to create payment source from card data")
class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode")
sealed class PostError(
override val message: String
) : StripeError(message) {
class Generic(statusCode: Int, val errorCode: String?) : PostError("postForm failed with code: $statusCode errorCode: $errorCode")
class Declined(statusCode: Int, val declineCode: StripeDeclineCode) : PostError("postForm failed with code: $statusCode declineCode: $declineCode")
class Failed(statusCode: Int, val failureCode: StripeFailureCode) : PostError("postForm failed with code: $statusCode failureCode: $failureCode")
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
/**
* Bank Transfer failure codes, as detailed here:
* https://stripe.com/docs/payments/sepa-debit#failed-payments
*/
sealed interface StripeFailureCode {
data class Known(val code: Code) : StripeFailureCode
data class Unknown(val code: String) : StripeFailureCode
val isKnown get() = this is Known
enum class Code(val code: String) {
REFER_TO_CUSTOMER("refer_to_customer"),
INSUFFICIENT_FUNDS("insufficient_funds"),
DEBIT_DISPUTED("debit_disputed"),
AUTHORIZATION_REVOKED("authorization_revoked"),
DEBIT_NOT_AUTHORIZED("debit_not_authorized"),
ACCOUNT_CLOSED("account_closed"),
BANK_ACCOUNT_RESTRICTED("bank_account_restricted"),
DEBIT_AUTHORIZATION_NOT_MATCH("debit_authorization_not_match"),
RECIPIENT_DECEASED("recipient_deceased"),
BRANCH_DOES_NOT_EXIST("branch_does_not_exist"),
INCORRECT_ACCOUNT_HOLDER_NAME("incorrect_account_holder_name"),
INVALID_ACCOUNT_NUMBER("invalid_account_number"),
GENERIC_COULD_NOT_PROCESS("generic_could_not_process")
}
companion object {
fun getFromCode(code: String?): StripeFailureCode {
if (code == null) {
return Unknown("null")
}
val typedCode: Code? = Code.values().firstOrNull { it.code == code }
return typedCode?.let { Known(typedCode) } ?: Unknown(code)
}
}
}