Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to re-fetch intent for 3ds2 #5138

Merged
merged 2 commits into from Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions payments-core/api/payments-core.api
Expand Up @@ -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 <init> (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 <init> (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 {
Expand Down
5 changes: 2 additions & 3 deletions payments-core/detekt-baseline.xml
Expand Up @@ -31,6 +31,7 @@
<ID>LargeClass:CardNumberEditTextTest.kt$CardNumberEditTextTest</ID>
<ID>LargeClass:CustomerSessionTest.kt$CustomerSessionTest</ID>
<ID>LargeClass:PaymentIntentFixtures.kt$PaymentIntentFixtures</ID>
<ID>LargeClass:SetupIntentFixtures.kt$SetupIntentFixtures</ID>
<ID>LargeClass:SourceParamsTest.kt$SourceParamsTest</ID>
<ID>LargeClass:Stripe.kt$Stripe</ID>
<ID>LargeClass:StripeApiRepository.kt$StripeApiRepository : StripeRepository</ID>
Expand All @@ -42,9 +43,9 @@
<ID>LongMethod:CustomerSessionOperationExecutor.kt$CustomerSessionOperationExecutor$@JvmSynthetic internal suspend fun execute( ephemeralKey: EphemeralKey, operation: EphemeralOperation )</ID>
<ID>LongMethod:CustomerSessionTest.kt$CustomerSessionTest$@BeforeTest fun setup()</ID>
<ID>LongMethod:CustomerSessionTest.kt$CustomerSessionTest$private suspend fun setupErrorProxy()</ID>
<ID>LongMethod:DefaultCardAccountRangeRepositoryTest.kt$DefaultCardAccountRangeRepositoryTest$@Test fun `repository with real sources returns expected results`()</ID>
<ID>LongMethod:GooglePayJsonFactoryTest.kt$GooglePayJsonFactoryTest$@Test fun testCreatePaymentMethodRequestJson()</ID>
<ID>LongMethod:PaymentAuthConfigTest.kt$PaymentAuthConfigTest$@Test fun testUiCustomizationWrapper()</ID>
<ID>LongMethod:PaymentFlowResultProcessor.kt$PaymentFlowResultProcessor$suspend fun processResult( unvalidatedResult: PaymentFlowResult.Unvalidated ): S</ID>
<ID>LongMethod:PaymentIntentJsonParser.kt$PaymentIntentJsonParser$override fun parse(json: JSONObject): PaymentIntent?</ID>
<ID>LongMethod:PaymentMethodJsonParser.kt$PaymentMethodJsonParser$override fun parse(json: JSONObject): PaymentMethod</ID>
<ID>LongMethod:SourceParams.kt$SourceParams$ override fun toParamMap(): Map&lt;String, Any></ID>
Expand All @@ -53,7 +54,6 @@
<ID>LongMethod:Stripe3ds2ChallengeResultProcessor.kt$DefaultStripe3ds2ChallengeResultProcessor$override suspend fun process( challengeResult: ChallengeResult ): PaymentFlowResult.Unvalidated</ID>
<ID>LongMethod:Stripe3ds2TransactionActivity.kt$Stripe3ds2TransactionActivity$public override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:StripeApiRepositoryTest.kt$StripeApiRepositoryTest$@Test fun getPaymentMethods_whenPopulated_returnsExpectedList()</ID>
<ID>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 )</ID>
<ID>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 )</ID>
<ID>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 )</ID>
<ID>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 )</ID>
Expand Down Expand Up @@ -107,7 +107,6 @@
<ID>MagicNumber:FraudDetectionDataRequestParamsFactory.kt$FraudDetectionDataRequestParamsFactory.Companion$60</ID>
<ID>MagicNumber:PaymentAuthConfig.kt$PaymentAuthConfig.Stripe3ds2Config$5</ID>
<ID>MagicNumber:PaymentAuthConfig.kt$PaymentAuthConfig.Stripe3ds2Config$99</ID>
<ID>MagicNumber:PaymentFlowResultProcessor.kt$PaymentIntentFlowResultProcessor$3</ID>
<ID>MagicNumber:PaymentMethodCreateParams.kt$PaymentMethodCreateParams.Card$4</ID>
<ID>MagicNumber:StripeColorUtils.kt$StripeColorUtils.Companion$0.114</ID>
<ID>MagicNumber:StripeColorUtils.kt$StripeColorUtils.Companion$0.299</ID>
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -69,8 +68,7 @@ constructor(
publishableKeyProvider,
stripeRepository,
Logger.getInstance(enableLogging),
workContext,
RetryDelaySupplier()
workContext
)
private val setupIntentFlowResultProcessor = SetupIntentFlowResultProcessor(
context,
Expand Down
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -34,7 +38,8 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
private val publishableKeyProvider: Provider<String>,
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)

Expand All @@ -56,11 +61,25 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
)
).let { stripeIntent ->
when {
stripeIntent.status == StripeIntent.Status.Succeeded ||
stripeIntent.status == StripeIntent.Status.RequiresCapture -> {
createStripeIntentResult(
stripeIntent,
SUCCEEDED,
failureMessageFactory.create(stripeIntent, result.flowOutcome)
)
}
shouldRefreshIntent(stripeIntent, result.flowOutcome) -> {
refreshStripeIntentUntilTerminalState(
val intent = refreshStripeIntentUntilTerminalState(
result.clientSecret,
requestOptions
)
val flowOutcome = determineFlowOutcome(intent, result.flowOutcome)
createStripeIntentResult(
intent,
flowOutcome,
failureMessageFactory.create(intent, result.flowOutcome)
)
}
shouldCancelIntentSource(stripeIntent, result.canCancelSource) -> {
val sourceId = result.sourceId.orEmpty()
Expand All @@ -73,25 +92,28 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
val threeDS2Data =
stripeIntent.nextActionData as? StripeIntent.NextActionData.SdkData.Use3DS2

requireNotNull(
val intent = requireNotNull(
cancelStripeIntentSource(
threeDS2Data?.threeDS2IntentId ?: stripeIntent.id.orEmpty(),
threeDS2Data?.publishableKey?.let { ApiRequest.Options(it) }
?: requestOptions,
sourceId
)
)
createStripeIntentResult(
intent,
result.flowOutcome,
failureMessageFactory.create(intent, result.flowOutcome)
)
}
else -> {
stripeIntent
createStripeIntentResult(
stripeIntent,
result.flowOutcome,
failureMessageFactory.create(stripeIntent, result.flowOutcome)
)
}
}
}.let { stripeIntent ->
createStripeIntentResult(
stripeIntent,
result.flowOutcome,
failureMessageFactory.create(stripeIntent, result.flowOutcome)
)
}
}

Expand All @@ -114,7 +136,24 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
// there is a delay when Stripe backend transfers its state out of "requires_action".
// For a PaymentIntent with such payment method, we will need to poll the refresh endpoint
// until the PaymentIntent reaches a deterministic state.
return flowOutcome == SUCCEEDED && stripeIntent.shouldRefresh()
val succeededMaybeRefresh = flowOutcome == SUCCEEDED && stripeIntent.shouldRefresh()

// For 3DS flow, if the transaction is still unexpectedly processing, refresh the
// PaymentIntent. This could happen if, for example, a payment is approved in a WebView,
// user closes the sheet, and the approval races with this fetch
val cancelledMaybeRefresh = flowOutcome == CANCELED &&
stripeIntent.status == StripeIntent.Status.Processing &&
stripeIntent.paymentMethod?.type == PaymentMethod.Type.Card

return succeededMaybeRefresh || cancelledMaybeRefresh
}

private fun determineFlowOutcome(intent: StripeIntent, originalFlowOutcome: Int): Int {
return when (intent.status) {
StripeIntent.Status.Succeeded,
StripeIntent.Status.RequiresCapture -> SUCCEEDED
else -> originalFlowOutcome
}
}

protected abstract suspend fun retrieveStripeIntent(
Expand All @@ -123,6 +162,12 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
expandFields: List<String>
): T?

protected abstract suspend fun refreshStripeIntent(
clientSecret: String,
requestOptions: ApiRequest.Options,
expandFields: List<String>
): T?

/**
* Keeps polling refresh endpoint for this [StripeIntent] until its status is no longer
* "requires_action".
Expand All @@ -134,17 +179,48 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
*
* @throws MaxRetryReachedException when max retry is reached and the status is still
* "requires_action".
* @throws InvalidRequestException if the intent is a [SetupIntent], refresh endpoint is only
* available for [PaymentIntent].
*/
@Throws(
MaxRetryReachedException::class,
InvalidRequestException::class
)
protected abstract suspend fun refreshStripeIntentUntilTerminalState(
private suspend fun refreshStripeIntentUntilTerminalState(
clientSecret: String,
requestOptions: ApiRequest.Options
): T
): T {
var remainingRetries = MAX_RETRIES

var stripeIntent = requireNotNull(
refreshStripeIntent(
clientSecret = clientSecret,
requestOptions = requestOptions,
expandFields = listOf()
)
)
while (shouldRetry(stripeIntent) && remainingRetries > 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,
Expand All @@ -162,8 +238,16 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
failureMessage: String?
): S

private fun shouldRetry(stripeIntent: StripeIntent): Boolean {
val requiresAction = stripeIntent.requiresAction()
val isCardPaymentProcessing = stripeIntent.status == StripeIntent.Status.Processing &&
stripeIntent.paymentMethod?.type == PaymentMethod.Type.Card
return requiresAction || isCardPaymentProcessing
}

internal companion object {
val EXPAND_PAYMENT_METHOD = listOf("payment_method")
const val MAX_RETRIES = 3
}
}

Expand All @@ -176,8 +260,7 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor(
@Named(PUBLISHABLE_KEY) publishableKeyProvider: () -> String,
stripeRepository: StripeRepository,
logger: Logger,
@IOContext workContext: CoroutineContext,
val retryDelaySupplier: RetryDelaySupplier
@IOContext workContext: CoroutineContext
) : PaymentFlowResultProcessor<PaymentIntent, PaymentIntentResult>(
context,
publishableKeyProvider,
Expand All @@ -196,39 +279,15 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor(
expandFields
)

override suspend fun refreshStripeIntentUntilTerminalState(
override suspend fun refreshStripeIntent(
clientSecret: String,
requestOptions: ApiRequest.Options
): PaymentIntent {
var remainingRetries = MAX_RETRIES

var stripeIntent = requireNotNull(
stripeRepository.refreshPaymentIntent(
clientSecret,
requestOptions
)
requestOptions: ApiRequest.Options,
expandFields: List<String>
): PaymentIntent? =
stripeRepository.refreshPaymentIntent(
clientSecret,
requestOptions
)
while (stripeIntent.requiresAction() && remainingRetries > 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,
Expand All @@ -251,10 +310,6 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor(
outcomeFromFlow,
failureMessage
)

internal companion object {
const val MAX_RETRIES = 3
}
}

/**
Expand Down Expand Up @@ -285,15 +340,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<String>
): SetupIntent? =
stripeRepository.retrieveSetupIntent(
clientSecret,
requestOptions,
expandFields
)
}

override suspend fun cancelStripeIntentSource(
stripeIntentId: String,
Expand Down