From 2a64368e21ce95b4a1fe23f5fa4d80d5159f9a34 Mon Sep 17 00:00:00 2001 From: jameswoo-stripe <99316447+jameswoo-stripe@users.noreply.github.com> Date: Wed, 8 Jun 2022 14:19:23 -0700 Subject: [PATCH 1/2] Add ability to re-fetch intent for 3ds2 --- payments-core/api/payments-core.api | 6 +- payments-core/detekt-baseline.xml | 5 +- .../stripe/android/StripePaymentController.kt | 4 +- .../payments/PaymentFlowResultProcessor.kt | 142 +++++++++--- .../android/model/PaymentIntentFixtures.kt | 214 ++++++++++++++++++ .../android/model/SetupIntentFixtures.kt | 180 +++++++++++++++ .../PaymentIntentFlowResultProcessorTest.kt | 125 +++++++++- .../SetupIntentFlowResultProcessorTest.kt | 153 +++++++++++-- 8 files changed, 773 insertions(+), 56 deletions(-) diff --git a/payments-core/api/payments-core.api b/payments-core/api/payments-core.api index 3a16e6c3956..4c541a42d8c 100644 --- a/payments-core/api/payments-core.api +++ b/payments-core/api/payments-core.api @@ -6411,11 +6411,11 @@ public final class com/stripe/android/payments/PaymentFlowResult$Unvalidated$Cre } public final class com/stripe/android/payments/PaymentIntentFlowResultProcessor_Factory : dagger/internal/Factory { - public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V - public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/payments/PaymentIntentFlowResultProcessor_Factory; + public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/payments/PaymentIntentFlowResultProcessor_Factory; public fun get ()Lcom/stripe/android/payments/PaymentIntentFlowResultProcessor; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Landroid/content/Context;Lkotlin/jvm/functions/Function0;Lcom/stripe/android/networking/StripeRepository;Lcom/stripe/android/core/Logger;Lkotlin/coroutines/CoroutineContext;Lcom/stripe/android/core/networking/RetryDelaySupplier;)Lcom/stripe/android/payments/PaymentIntentFlowResultProcessor; + public static fun newInstance (Landroid/content/Context;Lkotlin/jvm/functions/Function0;Lcom/stripe/android/networking/StripeRepository;Lcom/stripe/android/core/Logger;Lkotlin/coroutines/CoroutineContext;)Lcom/stripe/android/payments/PaymentIntentFlowResultProcessor; } public final class com/stripe/android/payments/SetupIntentFlowResultProcessor_Factory : dagger/internal/Factory { diff --git a/payments-core/detekt-baseline.xml b/payments-core/detekt-baseline.xml index f08202cf255..0a937dc914a 100644 --- a/payments-core/detekt-baseline.xml +++ b/payments-core/detekt-baseline.xml @@ -31,6 +31,7 @@ LargeClass:CardNumberEditTextTest.kt$CardNumberEditTextTest LargeClass:CustomerSessionTest.kt$CustomerSessionTest LargeClass:PaymentIntentFixtures.kt$PaymentIntentFixtures + LargeClass:SetupIntentFixtures.kt$SetupIntentFixtures LargeClass:SourceParamsTest.kt$SourceParamsTest LargeClass:Stripe.kt$Stripe LargeClass:StripeApiRepository.kt$StripeApiRepository : StripeRepository @@ -42,9 +43,9 @@ LongMethod:CustomerSessionOperationExecutor.kt$CustomerSessionOperationExecutor$@JvmSynthetic internal suspend fun execute( ephemeralKey: EphemeralKey, operation: EphemeralOperation ) LongMethod:CustomerSessionTest.kt$CustomerSessionTest$@BeforeTest fun setup() LongMethod:CustomerSessionTest.kt$CustomerSessionTest$private suspend fun setupErrorProxy() - LongMethod:DefaultCardAccountRangeRepositoryTest.kt$DefaultCardAccountRangeRepositoryTest$@Test fun `repository with real sources returns expected results`() LongMethod:GooglePayJsonFactoryTest.kt$GooglePayJsonFactoryTest$@Test fun testCreatePaymentMethodRequestJson() LongMethod:PaymentAuthConfigTest.kt$PaymentAuthConfigTest$@Test fun testUiCustomizationWrapper() + LongMethod:PaymentFlowResultProcessor.kt$PaymentFlowResultProcessor$suspend fun processResult( unvalidatedResult: PaymentFlowResult.Unvalidated ): S LongMethod:PaymentIntentJsonParser.kt$PaymentIntentJsonParser$override fun parse(json: JSONObject): PaymentIntent? LongMethod:PaymentMethodJsonParser.kt$PaymentMethodJsonParser$override fun parse(json: JSONObject): PaymentMethod LongMethod:SourceParams.kt$SourceParams$ override fun toParamMap(): Map<String, Any> @@ -53,7 +54,6 @@ LongMethod:Stripe3ds2ChallengeResultProcessor.kt$DefaultStripe3ds2ChallengeResultProcessor$override suspend fun process( challengeResult: ChallengeResult ): PaymentFlowResult.Unvalidated LongMethod:Stripe3ds2TransactionActivity.kt$Stripe3ds2TransactionActivity$public override fun onCreate(savedInstanceState: Bundle?) LongMethod:StripeApiRepositoryTest.kt$StripeApiRepositoryTest$@Test fun getPaymentMethods_whenPopulated_returnsExpectedList() - LongParameterList:CardNumberEditText.kt$CardNumberEditText$( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle, // TODO(mshafrir-stripe): make immutable after `CardWidgetViewModel` is integrated in `CardWidget` subclasses internal var workContext: CoroutineContext, private val cardAccountRangeRepository: CardAccountRangeRepository, private val staticCardAccountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges(), private val analyticsRequestExecutor: AnalyticsRequestExecutor, private val paymentAnalyticsRequestFactory: PaymentAnalyticsRequestFactory ) LongParameterList:ConfirmPaymentIntentParams.kt$ConfirmPaymentIntentParams.Companion$( paymentMethodCreateParams: PaymentMethodCreateParams, clientSecret: String, savePaymentMethod: Boolean? = null, mandateId: String? = null, mandateData: MandateDataParams? = null, setupFutureUsage: SetupFutureUsage? = null, shipping: Shipping? = null, paymentMethodOptions: PaymentMethodOptionsParams? = null ) LongParameterList:ConfirmPaymentIntentParams.kt$ConfirmPaymentIntentParams.Companion$( paymentMethodId: String, clientSecret: String, savePaymentMethod: Boolean? = null, paymentMethodOptions: PaymentMethodOptionsParams? = null, mandateId: String? = null, mandateData: MandateDataParams? = null, setupFutureUsage: SetupFutureUsage? = null, shipping: Shipping? = null ) LongParameterList:CustomerSession.kt$CustomerSession$( context: Context, stripeRepository: StripeRepository, publishableKey: String, stripeAccountId: String?, private val workContext: CoroutineContext = createCoroutineDispatcher(), private val operationIdFactory: OperationIdFactory = StripeOperationIdFactory(), private val timeSupplier: TimeSupplier = { Calendar.getInstance().timeInMillis }, ephemeralKeyManagerFactory: EphemeralKeyManager.Factory ) @@ -107,7 +107,6 @@ MagicNumber:FraudDetectionDataRequestParamsFactory.kt$FraudDetectionDataRequestParamsFactory.Companion$60 MagicNumber:PaymentAuthConfig.kt$PaymentAuthConfig.Stripe3ds2Config$5 MagicNumber:PaymentAuthConfig.kt$PaymentAuthConfig.Stripe3ds2Config$99 - MagicNumber:PaymentFlowResultProcessor.kt$PaymentIntentFlowResultProcessor$3 MagicNumber:PaymentMethodCreateParams.kt$PaymentMethodCreateParams.Card$4 MagicNumber:StripeColorUtils.kt$StripeColorUtils.Companion$0.114 MagicNumber:StripeColorUtils.kt$StripeColorUtils.Companion$0.299 diff --git a/payments-core/src/main/java/com/stripe/android/StripePaymentController.kt b/payments-core/src/main/java/com/stripe/android/StripePaymentController.kt index b33c2622dfc..049114bd539 100644 --- a/payments-core/src/main/java/com/stripe/android/StripePaymentController.kt +++ b/payments-core/src/main/java/com/stripe/android/StripePaymentController.kt @@ -16,7 +16,6 @@ import com.stripe.android.core.exception.StripeException import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.DefaultAnalyticsRequestExecutor -import com.stripe.android.core.networking.RetryDelaySupplier import com.stripe.android.model.ConfirmPaymentIntentParams import com.stripe.android.model.ConfirmSetupIntentParams import com.stripe.android.model.ConfirmStripeIntentParams @@ -69,8 +68,7 @@ constructor( publishableKeyProvider, stripeRepository, Logger.getInstance(enableLogging), - workContext, - RetryDelaySupplier() + workContext ) private val setupIntentFlowResultProcessor = SetupIntentFlowResultProcessor( context, diff --git a/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt b/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt index b52a0dd4a48..1d22b2dfb05 100644 --- a/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt +++ b/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt @@ -5,6 +5,7 @@ import com.stripe.android.PaymentController import com.stripe.android.PaymentIntentResult import com.stripe.android.SetupIntentResult import com.stripe.android.StripeIntentResult +import com.stripe.android.StripeIntentResult.Outcome.Companion.CANCELED import com.stripe.android.StripeIntentResult.Outcome.Companion.SUCCEEDED import com.stripe.android.core.Logger import com.stripe.android.core.exception.InvalidRequestException @@ -14,11 +15,14 @@ import com.stripe.android.core.injection.PUBLISHABLE_KEY import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.RetryDelaySupplier import com.stripe.android.model.PaymentIntent +import com.stripe.android.model.PaymentMethod import com.stripe.android.model.SetupIntent import com.stripe.android.model.StripeIntent import com.stripe.android.model.shouldRefresh import com.stripe.android.networking.StripeRepository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Named @@ -34,7 +38,8 @@ internal sealed class PaymentFlowResultProcessor, protected val stripeRepository: StripeRepository, private val logger: Logger, - private val workContext: CoroutineContext + private val workContext: CoroutineContext, + private val retryDelaySupplier: RetryDelaySupplier = RetryDelaySupplier() ) { private val failureMessageFactory = PaymentFlowFailureMessageFactory(context) @@ -56,11 +61,28 @@ internal sealed class PaymentFlowResultProcessor when { + stripeIntent.status == StripeIntent.Status.Succeeded -> { + createStripeIntentResult( + stripeIntent, + SUCCEEDED, + failureMessageFactory.create(stripeIntent, result.flowOutcome) + ) + } shouldRefreshIntent(stripeIntent, result.flowOutcome) -> { - refreshStripeIntentUntilTerminalState( + val intent = refreshStripeIntentUntilTerminalState( result.clientSecret, requestOptions ) + val flowOutcome = if (intent.status == StripeIntent.Status.Succeeded) { + SUCCEEDED + } else { + result.flowOutcome + } + createStripeIntentResult( + intent, + flowOutcome, + failureMessageFactory.create(intent, result.flowOutcome) + ) } shouldCancelIntentSource(stripeIntent, result.canCancelSource) -> { val sourceId = result.sourceId.orEmpty() @@ -73,7 +95,7 @@ internal sealed class PaymentFlowResultProcessor { - stripeIntent + createStripeIntentResult( + stripeIntent, + result.flowOutcome, + failureMessageFactory.create(stripeIntent, result.flowOutcome) + ) } } - }.let { stripeIntent -> - createStripeIntentResult( - stripeIntent, - result.flowOutcome, - failureMessageFactory.create(stripeIntent, result.flowOutcome) - ) } } @@ -114,7 +139,16 @@ internal sealed class PaymentFlowResultProcessor ): T? + protected abstract suspend fun refreshStripeIntent( + clientSecret: String, + requestOptions: ApiRequest.Options, + expandFields: List + ): T? + /** * Keeps polling refresh endpoint for this [StripeIntent] until its status is no longer * "requires_action". @@ -134,17 +174,48 @@ internal sealed class PaymentFlowResultProcessor 1) { + val delayMs = retryDelaySupplier.getDelayMillis( + MAX_RETRIES, + remainingRetries + ) + CoroutineScope(workContext).launch { + delay(delayMs) + } + stripeIntent = requireNotNull( + refreshStripeIntent( + clientSecret = clientSecret, + requestOptions = requestOptions, + expandFields = listOf() + ) + ) + remainingRetries-- + } + + if (shouldRetry(stripeIntent)) { + throw MaxRetryReachedException() + } else { + return stripeIntent + } + } /** * Cancels the source of this intent so that the payment method attached to it is cleared, @@ -162,8 +233,16 @@ internal sealed class PaymentFlowResultProcessor String, stripeRepository: StripeRepository, logger: Logger, - @IOContext workContext: CoroutineContext, - val retryDelaySupplier: RetryDelaySupplier + @IOContext workContext: CoroutineContext ) : PaymentFlowResultProcessor( context, publishableKeyProvider, @@ -196,8 +274,15 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor( expandFields ) - override suspend fun refreshStripeIntentUntilTerminalState( + override suspend fun refreshStripeIntent( clientSecret: String, + requestOptions: ApiRequest.Options, + expandFields: List + ): PaymentIntent? = + stripeRepository.refreshPaymentIntent( + clientSecret, + requestOptions + ) requestOptions: ApiRequest.Options ): PaymentIntent { var remainingRetries = MAX_RETRIES @@ -251,10 +336,6 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor( outcomeFromFlow, failureMessage ) - - internal companion object { - const val MAX_RETRIES = 3 - } } /** @@ -285,15 +366,16 @@ internal class SetupIntentFlowResultProcessor @Inject constructor( expandFields ) - override suspend fun refreshStripeIntentUntilTerminalState( + override suspend fun refreshStripeIntent( clientSecret: String, - requestOptions: ApiRequest.Options - ): SetupIntent { - throw InvalidRequestException( - message = "refresh endpoint is not available for SetupIntent. " + - "client_secret: $clientSecret" + requestOptions: ApiRequest.Options, + expandFields: List + ): SetupIntent? = + stripeRepository.retrieveSetupIntent( + clientSecret, + requestOptions, + expandFields ) - } override suspend fun cancelStripeIntentSource( stripeIntentId: String, diff --git a/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt b/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt index fa347d4aada..ecff18ad449 100644 --- a/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt +++ b/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt @@ -43,6 +43,220 @@ internal object PaymentIntentFixtures { requireNotNull(PARSER.parse(PI_SUCCEEDED_JSON)) } + val PI_PROCESSING_VISA_3DS2_JSON by lazy { + JSONObject( + """ + { + "id": "pi_3L8WOsLu5o3P18Zp191FpRSy", + "object": "payment_intent", + "client_secret": "pi_3L8WnkLu5o3P18Zp1cmTleAA_secret_7HJRZaBCOo6GPjmSITT6fxqPV", + "last_payment_error": null, + "livemode": false, + "next_action": null, + "status": "processing", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "confirmation_method": "automatic", + "created": 1654723610, + "currency": "usd", + "description": null, + "payment_method": { + "id": "pm_1F7J1bCRMbs6FrXfQKsYwO3U", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": null, + "exp_month": 8, + "exp_year": 2020, + "funding": "credit", + "generated_from": null, + "last4": "3220", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1565775851, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + }, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "acss_debit", + "afterpay_clearpay", + "alipay", + "klarna", + "us_bank_account", + "wechat_pay", + "affirm" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": { + "address": { + "city": "San Francisco", + "country": "US", + "line1": "510 Townsend St", + "line2": null, + "postal_code": "94102", + "state": "California" + }, + "carrier": null, + "name": "John Doe", + "phone": null, + "tracking_number": null + }, + "source": null + } + """.trimIndent() + ) + } + + val PI_PROCESSING_VISA_3DS2 by lazy { + requireNotNull(PARSER.parse(PI_PROCESSING_VISA_3DS2_JSON)) + } + + val PI_VISA_3DS2_SUCCEEDED_JSON by lazy { + JSONObject( + """ + { + "id": "pi_3L8WnkLu5o3P18Zp1cmTleAA", + "object": "payment_intent", + "client_secret": "pi_3L8WnkLu5o3P18Zp1cmTleAA_secret_7HJRZaBCOo6GPjmSITT6fxqPV", + "last_payment_error": null, + "livemode": false, + "next_action": null, + "status": "succeeded", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "confirmation_method": "automatic", + "created": 1654725152, + "currency": "usd", + "description": null, + "payment_method": { + "id": "pm_1F7J1bCRMbs6FrXfQKsYwO3U", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": null, + "exp_month": 8, + "exp_year": 2020, + "funding": "credit", + "generated_from": null, + "last4": "3220", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1565775851, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + }, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "acss_debit", + "afterpay_clearpay", + "alipay", + "klarna", + "us_bank_account", + "wechat_pay", + "affirm" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": { + "address": { + "city": "San Francisco", + "country": "US", + "line1": "510 Townsend St", + "line2": null, + "postal_code": "94102", + "state": "California" + }, + "carrier": null, + "name": "John Doe", + "phone": null, + "tracking_number": null + }, + "source": null + } + """.trimIndent() + ) + } + + val PI_VISA_3DS2_SUCCEEDED by lazy { + requireNotNull(PARSER.parse(PI_VISA_3DS2_SUCCEEDED_JSON)) + } + val PI_REQUIRES_MASTERCARD_3DS2_JSON by lazy { JSONObject( diff --git a/payments-core/src/test/java/com/stripe/android/model/SetupIntentFixtures.kt b/payments-core/src/test/java/com/stripe/android/model/SetupIntentFixtures.kt index a98fed08171..e6a306081f2 100644 --- a/payments-core/src/test/java/com/stripe/android/model/SetupIntentFixtures.kt +++ b/payments-core/src/test/java/com/stripe/android/model/SetupIntentFixtures.kt @@ -449,4 +449,184 @@ internal object SetupIntentFixtures { ) val SI_WITH_US_BANK_ACCOUNT_VERIFY_COMPLETED = PARSER.parse(SI_WITH_US_BANK_ACCOUNT_VERIFY_COMPLETED_JSON)!! + + val SI_3DS2_SUCCEEDED_JSON = JSONObject( + """ + { + "id": "seti_1L9F4bLu5o3P18Zp0IGLYrNZ", + "object": "setup_intent", + "application": null, + "cancellation_reason": null, + "client_secret": "seti_1L9F4bLu5o3P18Zp0IGLYrNZ_secret_Lqwy13NMvtzx88afOLNndJRei6rkl8H", + "created": 1654895333, + "customer": "cus_LqvlPQBk2QUmXl", + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1L9F4oLu5o3P18Zp8eQa9Mn2", + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1L9F4oLu5o3P18ZpahIpZYFz", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": "IE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "IE", + "exp_month": 6, + "exp_year": 2066, + "fingerprint": "UrRvw6ZlmgRswqMC", + "funding": "credit", + "generated_from": null, + "last4": "3220", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1654895346, + "customer": "cus_LqvlPQBk2QUmXl", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "us_bank_account", + "link" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + """.trimIndent() + ) + + val SI_3DS2_SUCCEEDED = PARSER.parse(SI_3DS2_SUCCEEDED_JSON)!! + + val SI_3DS2_PROCESSING_JSON = JSONObject( + """ + { + "id": "seti_1L9F4bLu5o3P18Zp0IGLYrNZ", + "object": "setup_intent", + "application": null, + "cancellation_reason": null, + "client_secret": "seti_1L9F4bLu5o3P18Zp0IGLYrNZ_secret_Lqwy13NMvtzx88afOLNndJRei6rkl8H", + "created": 1654895333, + "customer": "cus_LqvlPQBk2QUmXl", + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1L9F4oLu5o3P18Zp8eQa9Mn2", + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1L9F4oLu5o3P18ZpahIpZYFz", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": "IE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "IE", + "exp_month": 6, + "exp_year": 2066, + "fingerprint": "UrRvw6ZlmgRswqMC", + "funding": "credit", + "generated_from": null, + "last4": "3220", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1654895346, + "customer": "cus_LqvlPQBk2QUmXl", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "us_bank_account", + "link" + ], + "single_use_mandate": null, + "status": "processing", + "usage": "off_session" + } + """.trimIndent() + ) + + val SI_3DS2_PROCESSING = PARSER.parse(SI_3DS2_PROCESSING_JSON)!! } diff --git a/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt b/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt index 443f5795991..17923a6a371 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt @@ -38,7 +38,6 @@ internal class PaymentIntentFlowResultProcessorTest { mockStripeRepository, Logger.noop(), testDispatcher, - mock() ) @Test @@ -210,7 +209,129 @@ internal class PaymentIntentFlowResultProcessorTest { verify( mockStripeRepository, - times(PaymentIntentFlowResultProcessor.MAX_RETRIES) + times(PaymentFlowResultProcessor.MAX_RETRIES) + ).refreshPaymentIntent( + eq(clientSecret), + eq(requestOptions) + ) + } + + @Test + fun `3ds2 canceled with processing intent should succeed`() = + runTest { + whenever(mockStripeRepository.retrievePaymentIntent(any(), any(), any())).thenReturn( + PaymentIntentFixtures.PI_PROCESSING_VISA_3DS2 + ) + whenever(mockStripeRepository.refreshPaymentIntent(any(), any())).thenReturn( + PaymentIntentFixtures.PI_VISA_3DS2_SUCCEEDED + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + val result = processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + + verify(mockStripeRepository).retrievePaymentIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(1) + ).refreshPaymentIntent( + eq(clientSecret), + eq(requestOptions) + ) + + assertThat(result) + .isEqualTo( + PaymentIntentResult( + PaymentIntentFixtures.PI_VISA_3DS2_SUCCEEDED, + StripeIntentResult.Outcome.SUCCEEDED, + null + ) + ) + } + + @Test + fun `3ds2 canceled with succeeded intent should succeed`() = + runTest { + whenever(mockStripeRepository.retrievePaymentIntent(any(), any(), any())).thenReturn( + PaymentIntentFixtures.PI_VISA_3DS2_SUCCEEDED + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + val result = processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + + verify(mockStripeRepository).retrievePaymentIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(0) + ).refreshPaymentIntent( + eq(clientSecret), + eq(requestOptions) + ) + + assertThat(result) + .isEqualTo( + PaymentIntentResult( + PaymentIntentFixtures.PI_VISA_3DS2_SUCCEEDED, + StripeIntentResult.Outcome.SUCCEEDED, + null + ) + ) + } + + @Test + fun `3ds2 canceled reaches max retry with processing intent should fail`() = + runTest { + whenever(mockStripeRepository.retrievePaymentIntent(any(), any(), any())).thenReturn( + PaymentIntentFixtures.PI_PROCESSING_VISA_3DS2 + ) + whenever(mockStripeRepository.refreshPaymentIntent(any(), any())).thenReturn( + PaymentIntentFixtures.PI_PROCESSING_VISA_3DS2 + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + assertFailsWith { + processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + } + + verify(mockStripeRepository).retrievePaymentIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(PaymentFlowResultProcessor.MAX_RETRIES) ).refreshPaymentIntent( eq(clientSecret), eq(requestOptions) diff --git a/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt b/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt index 26e64b6346b..9e4d41ca546 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt @@ -6,25 +6,35 @@ import com.stripe.android.ApiKeyFixtures import com.stripe.android.SetupIntentResult import com.stripe.android.StripeIntentResult import com.stripe.android.core.Logger +import com.stripe.android.core.exception.MaxRetryReachedException import com.stripe.android.core.networking.ApiRequest import com.stripe.android.model.SetupIntentFixtures -import com.stripe.android.networking.AbsFakeStripeRepository +import com.stripe.android.networking.StripeRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import kotlin.test.assertFailsWith @RunWith(RobolectricTestRunner::class) @ExperimentalCoroutinesApi internal class SetupIntentFlowResultProcessorTest { private val testDispatcher = UnconfinedTestDispatcher() + private val mockStripeRepository: StripeRepository = mock() + private val processor = SetupIntentFlowResultProcessor( ApplicationProvider.getApplicationContext(), { ApiKeyFixtures.FAKE_PUBLISHABLE_KEY }, - FakeStripeRepository(), + mockStripeRepository, Logger.noop(), testDispatcher ) @@ -32,6 +42,13 @@ internal class SetupIntentFlowResultProcessorTest { @Test fun `processResult() when shouldCancelSource=true should return canceled SetupIntent`() = runTest { + whenever(mockStripeRepository.retrieveSetupIntent(any(), any(), any())).thenReturn( + SetupIntentFixtures.SI_NEXT_ACTION_REDIRECT + ) + whenever(mockStripeRepository.cancelSetupIntentSource(any(), any(), any())).thenReturn( + SetupIntentFixtures.CANCELLED + ) + val setupIntentResult = processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = "client_secret", @@ -49,17 +66,123 @@ internal class SetupIntentFlowResultProcessorTest { ) } - private class FakeStripeRepository : AbsFakeStripeRepository() { - override suspend fun retrieveSetupIntent( - clientSecret: String, - options: ApiRequest.Options, - expandFields: List - ) = SetupIntentFixtures.SI_NEXT_ACTION_REDIRECT - - override suspend fun cancelSetupIntentSource( - setupIntentId: String, - sourceId: String, - options: ApiRequest.Options - ) = SetupIntentFixtures.CANCELLED - } + @Test + fun `3ds2 canceled with processing intent should succeed`() = + runTest { + whenever(mockStripeRepository.retrieveSetupIntent(any(), any(), any())).thenReturn( + SetupIntentFixtures.SI_3DS2_PROCESSING, + SetupIntentFixtures.SI_3DS2_SUCCEEDED + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + val result = processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + + verify(mockStripeRepository).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(1) + ).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(emptyList()) + ) + + assertThat(result) + .isEqualTo( + SetupIntentResult( + SetupIntentFixtures.SI_3DS2_SUCCEEDED, + StripeIntentResult.Outcome.SUCCEEDED, + null + ) + ) + } + + @Test + fun `3ds2 canceled with succeeded intent should succeed`() = + runTest { + whenever(mockStripeRepository.retrieveSetupIntent(any(), any(), any())).thenReturn( + SetupIntentFixtures.SI_3DS2_SUCCEEDED + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + val result = processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + + verify(mockStripeRepository).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(0) + ).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(emptyList()) + ) + + assertThat(result) + .isEqualTo( + SetupIntentResult( + SetupIntentFixtures.SI_3DS2_SUCCEEDED, + StripeIntentResult.Outcome.SUCCEEDED, + null + ) + ) + } + + @Test + fun `3ds2 canceled reaches max retry with processing intent should fail`() = + runTest { + whenever(mockStripeRepository.retrieveSetupIntent(any(), any(), any())).thenReturn( + SetupIntentFixtures.SI_3DS2_PROCESSING + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + assertFailsWith { + processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + ) + ) + } + + verify(mockStripeRepository).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(PaymentFlowResultProcessor.MAX_RETRIES) + ).retrieveSetupIntent( + eq(clientSecret), + eq(requestOptions), + eq(emptyList()) + ) + } } From 5fc812c746c15865d4c51329447e4878e3b36ad3 Mon Sep 17 00:00:00 2001 From: jameswoo-stripe <99316447+jameswoo-stripe@users.noreply.github.com> Date: Thu, 21 Jul 2022 12:12:19 -0700 Subject: [PATCH 2/2] Rebase --- CHANGELOG.md | 1 + .../payments/PaymentFlowResultProcessor.kt | 50 ++++------------ .../android/model/PaymentIntentFixtures.kt | 4 +- .../PaymentIntentFlowResultProcessorTest.kt | 57 +++++++++++++++++-- .../SetupIntentFlowResultProcessorTest.kt | 6 +- 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9bc0b415cc..d5eca112446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## X.X.X ### Payments [Fixed][5308](https://github.com/stripe/stripe-android/pull/5308) OXXO so that processing is considered a successful terminal state, similar to Konbini and Boleto. +[Fixed][5138](https://github.com/stripe/stripe-android/pull/5138) Fixed an issue where PaymentSheet will show a failure even when 3DS2 Payment/SetupIntent is successful ## 20.7.0 - 2022-07-06 * This release adds additional support for Afterpay/Clearpay in PaymentSheet. diff --git a/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt b/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt index 1d22b2dfb05..5edd03c5d2d 100644 --- a/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt +++ b/payments-core/src/main/java/com/stripe/android/payments/PaymentFlowResultProcessor.kt @@ -61,7 +61,8 @@ internal sealed class PaymentFlowResultProcessor when { - stripeIntent.status == StripeIntent.Status.Succeeded -> { + stripeIntent.status == StripeIntent.Status.Succeeded || + stripeIntent.status == StripeIntent.Status.RequiresCapture -> { createStripeIntentResult( stripeIntent, SUCCEEDED, @@ -73,11 +74,7 @@ internal sealed class PaymentFlowResultProcessor SUCCEEDED + else -> originalFlowOutcome + } + } + protected abstract suspend fun retrieveStripeIntent( clientSecret: String, requestOptions: ApiRequest.Options, @@ -181,7 +186,7 @@ internal sealed class PaymentFlowResultProcessor 1) { - val delayMs = retryDelaySupplier.getDelayMillis( - 3, - remainingRetries - ) - delay(delayMs) - stripeIntent = requireNotNull( - stripeRepository.refreshPaymentIntent( - clientSecret, - requestOptions - ) - ) - remainingRetries-- - } - - if (stripeIntent.requiresAction()) { - throw MaxRetryReachedException() - } else { - return stripeIntent - } - } override suspend fun cancelStripeIntentSource( stripeIntentId: String, diff --git a/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt b/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt index ecff18ad449..56140068284 100644 --- a/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt +++ b/payments-core/src/test/java/com/stripe/android/model/PaymentIntentFixtures.kt @@ -142,7 +142,7 @@ internal object PaymentIntentFixtures { }, "source": null } - """.trimIndent() + """.trimIndent() ) } @@ -249,7 +249,7 @@ internal object PaymentIntentFixtures { }, "source": null } - """.trimIndent() + """.trimIndent() ) } diff --git a/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt b/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt index 17923a6a371..c6d2db6905f 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/PaymentIntentFlowResultProcessorTest.kt @@ -9,6 +9,7 @@ import com.stripe.android.core.Logger import com.stripe.android.core.exception.MaxRetryReachedException import com.stripe.android.core.networking.ApiRequest import com.stripe.android.model.PaymentIntentFixtures +import com.stripe.android.model.StripeIntent import com.stripe.android.networking.StripeRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -37,7 +38,7 @@ internal class PaymentIntentFlowResultProcessorTest { { ApiKeyFixtures.FAKE_PUBLISHABLE_KEY }, mockStripeRepository, Logger.noop(), - testDispatcher, + testDispatcher ) @Test @@ -232,7 +233,7 @@ internal class PaymentIntentFlowResultProcessorTest { val result = processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) @@ -260,6 +261,54 @@ internal class PaymentIntentFlowResultProcessorTest { ) } + @Test + fun `3ds2 canceled with requires capture intent should succeed`() = + runTest { + val refreshedPaymentIntent = PaymentIntentFixtures.PI_VISA_3DS2_SUCCEEDED.copy( + status = StripeIntent.Status.RequiresCapture + ) + + whenever(mockStripeRepository.retrievePaymentIntent(any(), any(), any())).thenReturn( + PaymentIntentFixtures.PI_PROCESSING_VISA_3DS2 + ) + whenever(mockStripeRepository.refreshPaymentIntent(any(), any())).thenReturn( + refreshedPaymentIntent + ) + + val clientSecret = "pi_3L8WOsLu5o3P18Zp191FpRSy_secret_5JIwIT1ooCwRm28AwreUAc6N4" + val requestOptions = ApiRequest.Options(apiKey = ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) + + val result = processor.processResult( + PaymentFlowResult.Unvalidated( + clientSecret = clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED + ) + ) + + verify(mockStripeRepository).retrievePaymentIntent( + eq(clientSecret), + eq(requestOptions), + eq(PaymentFlowResultProcessor.EXPAND_PAYMENT_METHOD) + ) + + verify( + mockStripeRepository, + times(1) + ).refreshPaymentIntent( + eq(clientSecret), + eq(requestOptions) + ) + + assertThat(result) + .isEqualTo( + PaymentIntentResult( + refreshedPaymentIntent, + StripeIntentResult.Outcome.SUCCEEDED, + null + ) + ) + } + @Test fun `3ds2 canceled with succeeded intent should succeed`() = runTest { @@ -273,7 +322,7 @@ internal class PaymentIntentFlowResultProcessorTest { val result = processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) @@ -318,7 +367,7 @@ internal class PaymentIntentFlowResultProcessorTest { processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) } diff --git a/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt b/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt index 9e4d41ca546..1d90a8cf715 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/SetupIntentFlowResultProcessorTest.kt @@ -80,7 +80,7 @@ internal class SetupIntentFlowResultProcessorTest { val result = processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) @@ -122,7 +122,7 @@ internal class SetupIntentFlowResultProcessorTest { val result = processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) @@ -165,7 +165,7 @@ internal class SetupIntentFlowResultProcessorTest { processor.processResult( PaymentFlowResult.Unvalidated( clientSecret = clientSecret, - flowOutcome = StripeIntentResult.Outcome.CANCELED, + flowOutcome = StripeIntentResult.Outcome.CANCELED ) ) }