Skip to content

Commit

Permalink
Attempt to wait for the intent callback to be available before failin…
Browse files Browse the repository at this point in the history
…g next step retrieval. (#9927)
  • Loading branch information
samer-stripe authored Jan 17, 2025
1 parent 8ed798a commit 6fa293e
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ interface ErrorReporter : FraudDetectionErrorReporter {
EXTERNAL_PAYMENT_METHODS_LAUNCH_SUCCESS(
eventName = "paymentsheet.external_payment_method.launch_success"
),
FOUND_CREATE_INTENT_CALLBACK_WHILE_POLLING(
eventName = "paymentsheet.polling_for_create_intent_callback.found"
),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ import com.stripe.android.paymentsheet.DeferredIntentValidator
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.R
import com.stripe.android.paymentsheet.state.PaymentElementLoader
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Named
import kotlin.time.Duration.Companion.seconds
import com.stripe.android.R as PaymentsCoreR

internal interface IntentConfirmationInterceptor {
Expand Down Expand Up @@ -241,7 +244,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor(
shippingValues: ConfirmPaymentIntentParams.Shipping?,
shouldSavePaymentMethod: Boolean,
): NextStep {
return when (val callback = IntentConfirmationInterceptor.createIntentCallback) {
return when (val callback = waitForIntentCallback()) {
is CreateIntentCallback -> {
handleDeferredIntentCreationFromPaymentMethod(
createIntentCallback = callback,
Expand Down Expand Up @@ -280,6 +283,31 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor(
)
}

private suspend fun waitForIntentCallback(): CreateIntentCallback? {
return retrieveCallback() ?: run {
val callback = withTimeoutOrNull(INTENT_CALLBACK_FETCH_TIMEOUT.seconds) {
var intentCallback: CreateIntentCallback? = null

while (intentCallback == null) {
delay(INTENT_CALLBACK_FETCH_INTERVAL)
intentCallback = retrieveCallback()
}

intentCallback
}

if (callback != null) {
errorReporter.report(ErrorReporter.SuccessEvent.FOUND_CREATE_INTENT_CALLBACK_WHILE_POLLING)
}

callback
}
}

private fun retrieveCallback(): CreateIntentCallback? {
return IntentConfirmationInterceptor.createIntentCallback
}

private suspend fun handleDeferredIntentCreationFromPaymentMethod(
createIntentCallback: CreateIntentCallback,
intentConfiguration: PaymentSheet.IntentConfiguration,
Expand Down Expand Up @@ -409,6 +437,8 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor(
}

private companion object {
private const val INTENT_CALLBACK_FETCH_TIMEOUT = 2
private const val INTENT_CALLBACK_FETCH_INTERVAL = 5L
private val GENERIC_STRIPE_MESSAGE = R.string.stripe_something_went_wrong
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.stripe.android.paymentelement.confirmation
import com.google.common.truth.Truth.assertThat
import com.stripe.android.core.exception.APIException
import com.stripe.android.core.networking.ApiRequest
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.isInstanceOf
import com.stripe.android.model.ConfirmPaymentIntentParams
Expand All @@ -28,6 +29,8 @@ import com.stripe.android.testing.AbsFakeStripeRepository
import com.stripe.android.testing.FakeErrorReporter
import com.stripe.android.testing.PaymentMethodFactory
import com.stripe.android.utils.IntentConfirmationInterceptorTestRule
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -127,8 +130,9 @@ class DefaultIntentConfirmationInterceptorTest {
}

@Test
fun `Fails if invoked without a confirm callback for existing payment method`() = runTest {
val errorReporter = FakeErrorReporter()
fun `Fails if invoked without a confirm callback for existing payment method`() = testNoConfirmCallback(
userMessage = CREATE_INTENT_CALLBACK_MESSAGE.resolvableString,
) { errorReporter ->
val interceptor = DefaultIntentConfirmationInterceptor(
stripeRepository = object : AbsFakeStripeRepository() {},
publishableKeyProvider = { "pk_test_123" },
Expand All @@ -137,7 +141,7 @@ class DefaultIntentConfirmationInterceptorTest {
allowsManualConfirmation = false,
)

val nextStep = interceptor.intercept(
interceptor.intercept(
initializationMode = InitializationMode.DeferredIntent(
intentConfiguration = PaymentSheet.IntentConfiguration(
mode = PaymentSheet.IntentConfiguration.Mode.Payment(
Expand All @@ -150,23 +154,12 @@ class DefaultIntentConfirmationInterceptorTest {
paymentMethodOptionsParams = null,
shippingValues = null,
)

assertThat(nextStep).isInstanceOf<IntentConfirmationInterceptor.NextStep.Fail>()

val failedStep = nextStep.asFail()

assertThat(failedStep.cause).isInstanceOf<IllegalStateException>()
assertThat(failedStep.cause.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE)
assertThat(failedStep.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE.resolvableString)

assertThat(errorReporter.getLoggedErrors()).containsExactly(
ErrorReporter.ExpectedErrorEvent.CREATE_INTENT_CALLBACK_NULL.eventName,
)
}

@Test
fun `Fails if invoked without a confirm callback for new payment method`() = runTest {
val errorReporter = FakeErrorReporter()
fun `Fails if invoked without a confirm callback for new payment method`() = testNoConfirmCallback(
userMessage = CREATE_INTENT_CALLBACK_MESSAGE.resolvableString,
) { errorReporter ->
val interceptor = DefaultIntentConfirmationInterceptor(
stripeRepository = object : AbsFakeStripeRepository() {
override suspend fun createPaymentMethod(
Expand All @@ -182,29 +175,18 @@ class DefaultIntentConfirmationInterceptorTest {
allowsManualConfirmation = false,
)

val nextStep = interceptor.intercept(
interceptor.intercept(
initializationMode = InitializationMode.DeferredIntent(mock()),
paymentMethodCreateParams = PaymentMethodCreateParamsFixtures.DEFAULT_CARD,
shippingValues = null,
customerRequestedSave = false,
)

assertThat(nextStep).isInstanceOf<IntentConfirmationInterceptor.NextStep.Fail>()

val failedStep = nextStep.asFail()

assertThat(failedStep.cause).isInstanceOf<IllegalStateException>()
assertThat(failedStep.cause.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE)
assertThat(failedStep.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE.resolvableString)

assertThat(errorReporter.getLoggedErrors()).containsExactly(
ErrorReporter.ExpectedErrorEvent.CREATE_INTENT_CALLBACK_NULL.eventName,
)
}

@Test
fun `Message for live key when error without confirm callback is user friendly`() = runTest {
val errorReporter = FakeErrorReporter()
fun `Message for live key when error without confirm callback is user friendly`() = testNoConfirmCallback(
userMessage = PaymentsCoreR.string.stripe_internal_error.resolvableString
) { errorReporter ->
val interceptor = DefaultIntentConfirmationInterceptor(
stripeRepository = object : AbsFakeStripeRepository() {},
publishableKeyProvider = { "pk_live_123" },
Expand All @@ -213,7 +195,7 @@ class DefaultIntentConfirmationInterceptorTest {
allowsManualConfirmation = false,
)

val nextStep = interceptor.intercept(
interceptor.intercept(
initializationMode = InitializationMode.DeferredIntent(
intentConfiguration = PaymentSheet.IntentConfiguration(
mode = PaymentSheet.IntentConfiguration.Mode.Payment(
Expand All @@ -226,18 +208,72 @@ class DefaultIntentConfirmationInterceptorTest {
paymentMethodOptionsParams = null,
shippingValues = null,
)
}

assertThat(nextStep).isInstanceOf<IntentConfirmationInterceptor.NextStep.Fail>()
@Test
fun `Succeeds if callback is found before timeout time`() {
val dispatcher = StandardTestDispatcher()

runTest(dispatcher) {
val errorReporter = FakeErrorReporter()
val paymentMethod = PaymentMethodFactory.card()
val interceptor = DefaultIntentConfirmationInterceptor(
stripeRepository = object : AbsFakeStripeRepository() {
override suspend fun createPaymentMethod(
paymentMethodCreateParams: PaymentMethodCreateParams,
options: ApiRequest.Options
): Result<PaymentMethod> {
return Result.success(paymentMethod)
}

override suspend fun retrieveStripeIntent(
clientSecret: String,
options: ApiRequest.Options,
expandFields: List<String>
): Result<StripeIntent> {
return Result.success(PaymentIntentFixtures.PI_SUCCEEDED)
}
},
publishableKeyProvider = { "pk_live_123" },
stripeAccountIdProvider = { null },
errorReporter = errorReporter,
allowsManualConfirmation = false,
)

val failedStep = nextStep.asFail()
val interceptJob = async {
interceptor.intercept(
initializationMode = InitializationMode.DeferredIntent(
intentConfiguration = PaymentSheet.IntentConfiguration(
mode = PaymentSheet.IntentConfiguration.Mode.Payment(
amount = 1099L,
currency = "usd",
),
),
),
paymentMethod = paymentMethod,
paymentMethodOptionsParams = null,
shippingValues = null,
)
}

assertThat(failedStep.cause).isInstanceOf<IllegalStateException>()
assertThat(failedStep.cause.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE)
assertThat(failedStep.message).isEqualTo(PaymentsCoreR.string.stripe_internal_error.resolvableString)
dispatcher.scheduler.advanceTimeBy(1000)
assertThat(interceptJob.isActive).isTrue()

assertThat(errorReporter.getLoggedErrors()).containsExactly(
ErrorReporter.ExpectedErrorEvent.CREATE_INTENT_CALLBACK_NULL.eventName,
)
IntentConfirmationInterceptor.createIntentCallback = succeedingCreateIntentCallback(paymentMethod)

dispatcher.scheduler.advanceTimeBy(1001)

assertThat(interceptJob.isActive).isFalse()
assertThat(interceptJob.isCompleted).isTrue()

val nextStep = interceptJob.await()

assertThat(nextStep).isInstanceOf<IntentConfirmationInterceptor.NextStep.Complete>()

assertThat(errorReporter.getLoggedErrors()).containsExactly(
ErrorReporter.SuccessEvent.FOUND_CREATE_INTENT_CALLBACK_WHILE_POLLING.eventName,
)
}
}

@Test
Expand Down Expand Up @@ -637,6 +673,48 @@ class DefaultIntentConfirmationInterceptorTest {
)
}

private fun testNoConfirmCallback(
userMessage: ResolvableString,
interceptCall: suspend (errorReporter: ErrorReporter) -> IntentConfirmationInterceptor.NextStep
) {
val errorReporter = FakeErrorReporter()
val dispatcher = StandardTestDispatcher()

runTest(dispatcher) {
val interceptJob = async {
interceptCall(errorReporter)
}

assertThat(interceptJob.isActive).isTrue()

dispatcher.scheduler.advanceTimeBy(1000)

assertThat(interceptJob.isActive).isTrue()

dispatcher.scheduler.advanceTimeBy(1000)

assertThat(interceptJob.isActive).isTrue()

dispatcher.scheduler.advanceTimeBy(1)

assertThat(interceptJob.isActive).isFalse()

val nextStep = interceptJob.await()

assertThat(nextStep).isInstanceOf<IntentConfirmationInterceptor.NextStep.Fail>()

val failedStep = nextStep.asFail()

assertThat(failedStep.cause).isInstanceOf<IllegalStateException>()
assertThat(failedStep.cause.message).isEqualTo(CREATE_INTENT_CALLBACK_MESSAGE)
assertThat(failedStep.message).isEqualTo(userMessage)

assertThat(errorReporter.getLoggedErrors()).containsExactly(
ErrorReporter.ExpectedErrorEvent.CREATE_INTENT_CALLBACK_NULL.eventName,
)
}
}

private fun succeedingCreateIntentCallback(
expectedPaymentMethod: PaymentMethod,
): CreateIntentCallback {
Expand Down

0 comments on commit 6fa293e

Please sign in to comment.