Skip to content

Commit

Permalink
Add ability to re-fetch intent for 3ds2
Browse files Browse the repository at this point in the history
  • Loading branch information
jameswoo-stripe committed Jun 10, 2022
1 parent 96ad9f9 commit 8efbbee
Show file tree
Hide file tree
Showing 8 changed files with 763 additions and 86 deletions.
6 changes: 3 additions & 3 deletions payments-core/api/payments-core.api
Expand Up @@ -4845,11 +4845,11 @@ public final class com/stripe/android/payments/PaymentFlowResult$Unvalidated : a
}

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,36 +61,55 @@ internal sealed class PaymentFlowResultProcessor<T : StripeIntent, out S : Strip
)
).let { stripeIntent ->
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()
logger.debug(
"Canceling source '$sourceId' for '${stripeIntent.javaClass.simpleName}'"
)

requireNotNull(
val intent = requireNotNull(
cancelStripeIntentSource(
stripeIntent,
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 @@ -108,7 +132,16 @@ 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
}

protected abstract suspend fun retrieveStripeIntent(
Expand All @@ -117,6 +150,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 @@ -128,17 +167,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
requestOptions: ApiRequest.Options,
): 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 @@ -156,8 +226,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 @@ -170,8 +248,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, stripeRepository, logger, workContext
) {
Expand All @@ -186,39 +263,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
)
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(
stripeIntent: PaymentIntent,
Expand All @@ -241,10 +294,6 @@ internal class PaymentIntentFlowResultProcessor @Inject constructor(
outcomeFromFlow,
failureMessage
)

internal companion object {
const val MAX_RETRIES = 3
}
}

/**
Expand All @@ -271,15 +320,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(
stripeIntent: SetupIntent,
Expand Down

0 comments on commit 8efbbee

Please sign in to comment.