diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/ConfirmationTokenConfirmationInterceptor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/ConfirmationTokenConfirmationInterceptor.kt index 9b833e95845..bd1cb54bd64 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/ConfirmationTokenConfirmationInterceptor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/ConfirmationTokenConfirmationInterceptor.kt @@ -23,6 +23,7 @@ import com.stripe.android.paymentelement.confirmation.ConfirmationHandler import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption import com.stripe.android.paymentelement.confirmation.intent.IntentConfirmationDefinition.Args import com.stripe.android.paymentelement.confirmation.utils.ConfirmActionHelper +import com.stripe.android.paymentelement.confirmation.utils.toConfirmParamsSetupFutureUsage import com.stripe.android.payments.DefaultReturnUrl import com.stripe.android.paymentsheet.CreateIntentResult import com.stripe.android.paymentsheet.PaymentSheet @@ -205,7 +206,7 @@ internal class ConfirmationTokenConfirmationInterceptor @AssistedInject construc returnUrl = DefaultReturnUrl.create(context).value, paymentMethodId = (confirmationOption as? PaymentMethodConfirmationOption.Saved)?.paymentMethod?.id, paymentMethodData = (confirmationOption as? PaymentMethodConfirmationOption.New)?.createParams, - setUpFutureUsage = confirmationOption.optionsParams?.setupFutureUsage(), + setUpFutureUsage = resolveSetupFutureUsage(confirmationOption.optionsParams), shipping = shippingValues, mandateDataParams = MandateDataParams(MandateDataParams.Type.Online.DEFAULT).takeIf { when (confirmationOption) { @@ -240,8 +241,7 @@ internal class ConfirmationTokenConfirmationInterceptor @AssistedInject construc ConfirmationTokenClientContextParams( mode = mode.code, currency = mode.currency, - // Use paymentMethodOptions to correctly set PMO SFU value - setupFutureUsage = paymentMethodOptions?.setupFutureUsage(), + setupFutureUsage = resolveSetupFutureUsage(paymentMethodOptions), captureMethod = (mode as? DeferredIntentParams.Mode.Payment)?.captureMethod?.code, paymentMethodTypes = paymentMethodTypes, onBehalfOf = onBehalfOf, @@ -253,6 +253,20 @@ internal class ConfirmationTokenConfirmationInterceptor @AssistedInject construc } } + /** + * Resolves the setup future usage value following this priority: + * 1. User checkbox (via paymentMethodOptions) - highest priority + * 2. Payment method options from IntentConfiguration + * 3. Intent configuration setupFutureUse - fallback + * 4. null - when nothing is set + */ + private fun resolveSetupFutureUsage( + paymentMethodOptions: PaymentMethodOptionsParams? + ): ConfirmPaymentIntentParams.SetupFutureUsage? { + return paymentMethodOptions?.setupFutureUsage() + ?: intentConfiguration.mode.setupFutureUse?.toConfirmParamsSetupFutureUsage() + } + @AssistedFactory interface Factory { fun create( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/interceptor/ConfirmationTokenConfirmationInterceptorTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/interceptor/ConfirmationTokenConfirmationInterceptorTest.kt index 8865008aae4..9e832fb4b9f 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/interceptor/ConfirmationTokenConfirmationInterceptorTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/interceptor/ConfirmationTokenConfirmationInterceptorTest.kt @@ -990,88 +990,58 @@ class ConfirmationTokenConfirmationInterceptorTest { assertThat(observedParams[0].clientContext?.requireCvcRecollection).isEqualTo(true) } - @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) @Test + @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) fun `SFU priority - user checkbox takes highest priority over PMO SFU for New payment method`() { - val observedParams = Turbine() - runConfirmationTokenInterceptorScenario( - observedParams = observedParams, - initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( - intentConfiguration = PaymentSheet.IntentConfiguration( - mode = PaymentSheet.IntentConfiguration.Mode.Payment( - amount = 1099L, - currency = "usd", - paymentMethodOptions = PaymentSheet.IntentConfiguration.Mode.Payment.PaymentMethodOptions( - mapOf( - PaymentMethod.Type.Card to PaymentSheet.IntentConfiguration.SetupFutureUse.OnSession - ) - ) - ), - ) - ), - ) { interceptor -> - val confirmationOption = PaymentMethodConfirmationOption.New( - createParams = PaymentMethodCreateParamsFixtures.DEFAULT_CARD, - optionsParams = PaymentMethodOptionsParams.Card( - setupFutureUsage = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession - ), - extraParams = null, - shouldSave = true, - passiveCaptchaParams = null, - ) - - interceptor.intercept( - intent = PaymentIntentFactory.create(), - confirmationOption = confirmationOption, - shippingValues = null, - ) - - // User checkbox sets OffSession, should not be overridden by PMO SFU - assertThat(observedParams.awaitItem().setUpFutureUsage) - .isEqualTo(ConfirmPaymentIntentParams.SetupFutureUsage.OffSession) - } + runSfuPriorityTest( + pmoSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OnSession, + userCheckbox = true, + expectedSfu = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession + ) } @Test @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) fun `SFU priority - PMO SFU used when no user checkbox for New payment method`() { - val observedParams = Turbine() - runConfirmationTokenInterceptorScenario( - observedParams = observedParams, - initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( - intentConfiguration = PaymentSheet.IntentConfiguration( - mode = PaymentSheet.IntentConfiguration.Mode.Payment( - amount = 1099L, - currency = "usd", - paymentMethodOptions = PaymentSheet.IntentConfiguration.Mode.Payment.PaymentMethodOptions( - mapOf( - PaymentMethod.Type.Card to PaymentSheet.IntentConfiguration.SetupFutureUse.OffSession - ) - ) - ), - ) - ), - ) { interceptor -> - interceptor.interceptDefaultNewPaymentMethod() + runSfuPriorityTest( + pmoSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OffSession, + expectedSfu = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession + ) + } - // No user checkbox, should use PMO SFU from IntentConfiguration - assertThat(observedParams.awaitItem().setUpFutureUsage) - .isEqualTo(ConfirmPaymentIntentParams.SetupFutureUsage.OffSession) - } + @Test + @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) + fun `SFU priority - user checkbox takes priority over intent SFU`() { + runSfuPriorityTest( + intentSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OnSession, + userCheckbox = true, + expectedSfu = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession + ) } @Test - fun `SFU priority - no SFU when no user checkbox and no PMO SFU`() { - val observedParams = Turbine() - runConfirmationTokenInterceptorScenario( - observedParams = observedParams, - initializationMode = DEFAULT_DEFERRED_INTENT, - ) { interceptor -> - interceptor.interceptDefaultNewPaymentMethod() + @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) + fun `SFU priority - PMO SFU takes priority over intent SFU`() { + runSfuPriorityTest( + intentSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OnSession, + pmoSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OffSession, + expectedSfu = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession + ) + } - // No user checkbox, no PMO SFU - assertThat(observedParams.awaitItem().setUpFutureUsage).isNull() - } + @Test + fun `SFU priority - intent SFU used as fallback when no user checkbox and no PMO SFU`() { + runSfuPriorityTest( + intentSfu = PaymentSheet.IntentConfiguration.SetupFutureUse.OnSession, + expectedSfu = ConfirmPaymentIntentParams.SetupFutureUsage.OnSession + ) + } + + @Test + fun `SFU priority - no SFU when no user checkbox, no PMO SFU, and no intent SFU`() { + runSfuPriorityTest( + expectedSfu = null + ) } @Test @@ -1161,12 +1131,14 @@ class ConfirmationTokenConfirmationInterceptorTest { observedParams: Turbine = Turbine(), retrievedIntentStatus: StripeIntent.Status = StripeIntent.Status.Succeeded, initializationMode: PaymentElementLoader.InitializationMode = DEFAULT_DEFERRED_INTENT, + isLiveMode: Boolean = true, block: suspend (IntentConfirmationInterceptor) -> Unit ) { runInterceptorScenario( initializationMode = initializationMode, scenario = InterceptorTestScenario( ephemeralKeySecret = "ek_test_123", + publishableKeyProvider = { if (isLiveMode) "pk_live_123" else "pk_test_123" }, stripeRepository = createFakeStripeRepositoryForConfirmationToken( observedParams, retrievedIntentStatus, @@ -1178,4 +1150,74 @@ class ConfirmationTokenConfirmationInterceptorTest { test = block ) } + + /** + * Helper to test SFU priority scenarios with cleaner syntax. + * + * Validates that both setUpFutureUsage and clientContext.setupFutureUsage + * use the same resolution logic and stay in sync. + * + * @param intentSfu Setup future usage on the intent configuration + * @param pmoSfu Setup future usage in payment method options (PMO) + * @param userCheckbox Whether user checked the "save for future use" checkbox (highest priority) + * @param expectedSfu Expected SFU result + */ + @OptIn(PaymentMethodOptionsSetupFutureUsagePreview::class) + private fun runSfuPriorityTest( + intentSfu: PaymentSheet.IntentConfiguration.SetupFutureUse? = null, + pmoSfu: PaymentSheet.IntentConfiguration.SetupFutureUse? = null, + userCheckbox: Boolean = false, + expectedSfu: ConfirmPaymentIntentParams.SetupFutureUsage?, + ) { + val observedParams = Turbine() + + // Build payment mode with optional PMO + val paymentMode = PaymentSheet.IntentConfiguration.Mode.Payment( + amount = 1099L, + currency = "usd", + setupFutureUse = intentSfu, + paymentMethodOptions = pmoSfu?.let { + PaymentSheet.IntentConfiguration.Mode.Payment.PaymentMethodOptions( + mapOf(PaymentMethod.Type.Card to it) + ) + } + ) + + runConfirmationTokenInterceptorScenario( + observedParams = observedParams, + isLiveMode = false, + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration(mode = paymentMode) + ), + ) { interceptor -> + if (userCheckbox) { + // Test with user checkbox checked (always OffSession) + val confirmationOption = PaymentMethodConfirmationOption.New( + createParams = PaymentMethodCreateParamsFixtures.DEFAULT_CARD, + optionsParams = PaymentMethodOptionsParams.Card( + setupFutureUsage = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession + ), + extraParams = null, + shouldSave = true, + passiveCaptchaParams = null, + ) + interceptor.intercept( + intent = PaymentIntentFactory.create(), + confirmationOption = confirmationOption, + shippingValues = null, + ) + } else { + // Test without user checkbox + interceptor.interceptDefaultNewPaymentMethod() + } + + val params = observedParams.awaitItem() + + // Verify main SFU value + assertThat(params.setUpFutureUsage).isEqualTo(expectedSfu) + + // Verify clientContext SFU matches (clientContext is only populated in test mode) + assertThat(params.clientContext?.setupFutureUsage).isEqualTo(expectedSfu) + } + } }