From 788bd12ca0fb93e41c84966aa04680f8d1ec48e4 Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Fri, 10 Jul 2026 13:10:47 -0600 Subject: [PATCH] Introduce CheckoutController state holders and state loader with tests. Committed-By-Agent: claude --- .../checkout/CheckoutConfigurationMerger.kt | 20 +- .../CheckoutConfirmationStateHolder.kt | 55 ++++ .../android/checkout/CheckoutController.kt | 26 +- .../checkout/CheckoutControllerState.kt | 64 ++++ .../checkout/CheckoutControllerStateHolder.kt | 34 ++ .../android/checkout/CheckoutStateLoader.kt | 82 +++++ .../stripe/android/checkout/InternalState.kt | 18 +- .../injection/CheckoutControllerComponent.kt | 223 +++++++++++++- .../checkout/injection/MerchantDisplayName.kt | 6 + .../CheckoutConfirmationStateHolderTest.kt | 105 +++++++ .../checkout/CheckoutControllerTest.kt | 275 +++++++++++++++++ .../checkout/CheckoutStateLoaderTest.kt | 291 ++++++++++++++++++ 12 files changed, 1176 insertions(+), 23 deletions(-) create mode 100644 paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfirmationStateHolder.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerState.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerStateHolder.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutStateLoader.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/checkout/injection/MerchantDisplayName.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutConfirmationStateHolderTest.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutControllerTest.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutStateLoaderTest.kt diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfigurationMerger.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfigurationMerger.kt index af1d2c873e9..6f7f678fca2 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfigurationMerger.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfigurationMerger.kt @@ -4,16 +4,28 @@ import com.stripe.android.paymentelement.CheckoutSessionPreview import com.stripe.android.paymentelement.EmbeddedPaymentElement import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.paymentsheet.addresselement.AddressDetails +import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse + +@OptIn(CheckoutSessionPreview::class) +internal interface CheckoutSessionData { + val checkoutSessionResponse: CheckoutSessionResponse + val shippingName: String? + val billingName: String? + val shippingPhoneNumber: String? + val billingPhoneNumber: String? + val shippingAddress: Address.State? + val billingAddress: Address.State? +} @OptIn(CheckoutSessionPreview::class) internal sealed class CheckoutConfigurationMerger { - abstract fun forCheckoutSession(state: InternalState): T + abstract fun forCheckoutSession(state: CheckoutSessionData): T class PaymentSheetConfiguration( private val config: PaymentSheet.Configuration, ) : CheckoutConfigurationMerger() { - override fun forCheckoutSession(state: InternalState): PaymentSheet.Configuration { + override fun forCheckoutSession(state: CheckoutSessionData): PaymentSheet.Configuration { val merged = mergeCheckoutSessionData( existingBillingDetails = config.defaultBillingDetails, existingShippingDetails = config.shippingDetails, @@ -32,7 +44,7 @@ internal sealed class CheckoutConfigurationMerger { class EmbeddedConfiguration( private val config: EmbeddedPaymentElement.Configuration, ) : CheckoutConfigurationMerger() { - override fun forCheckoutSession(state: InternalState): EmbeddedPaymentElement.Configuration { + override fun forCheckoutSession(state: CheckoutSessionData): EmbeddedPaymentElement.Configuration { val merged = mergeCheckoutSessionData( existingBillingDetails = config.defaultBillingDetails, existingShippingDetails = config.shippingDetails, @@ -58,7 +70,7 @@ private data class MergedDetails( private fun mergeCheckoutSessionData( existingBillingDetails: PaymentSheet.BillingDetails?, existingShippingDetails: AddressDetails?, - state: InternalState, + state: CheckoutSessionData, ): MergedDetails { val response = state.checkoutSessionResponse return MergedDetails( diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfirmationStateHolder.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfirmationStateHolder.kt new file mode 100644 index 00000000000..816b524479f --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfirmationStateHolder.kt @@ -0,0 +1,55 @@ +package com.stripe.android.checkout + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.stripe.android.core.injection.ViewModelScope +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder +import com.stripe.android.paymentsheet.model.PaymentSelection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the confirmation [State] for [CheckoutController], persisting it in [SavedStateHandle] so it + * survives process death. Kept separate from the controller so the confirmation flow can depend on + * this holder directly rather than reaching back into the controller. + */ +@Singleton +internal class CheckoutConfirmationStateHolder @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val selectionHolder: EmbeddedSelectionHolder, + @ViewModelScope private val coroutineScope: CoroutineScope, +) { + var state: State? + get() = savedStateHandle[CONFIRMATION_STATE_KEY] + set(value) { + savedStateHandle[CONFIRMATION_STATE_KEY] = value + } + + val stateFlow: StateFlow + get() = savedStateHandle.getStateFlow(CONFIRMATION_STATE_KEY, null) + + init { + coroutineScope.launch { + selectionHolder.selection.collect { selection -> + state = state?.copy(selection = selection) + } + } + } + + @Parcelize + data class State( + val paymentMethodMetadata: PaymentMethodMetadata, + val selection: PaymentSelection?, + val configuration: EmbeddedPaymentElement.Configuration, + ) : Parcelable + + companion object { + const val CONFIRMATION_STATE_KEY = "CheckoutController_ConfirmationState" + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt index da0fe3ade0e..cca7685bab9 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt @@ -8,12 +8,11 @@ import androidx.lifecycle.SavedStateHandle import com.stripe.android.checkout.injection.DaggerCheckoutControllerComponent import com.stripe.android.core.injection.ViewModelScope import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.paymentsheet.repositories.CheckoutSessionRepository import dev.drewhamilton.poko.Poko import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.parcelize.Parcelize import javax.inject.Inject import javax.inject.Singleton @@ -25,15 +24,32 @@ import javax.inject.Singleton class CheckoutController @Inject internal constructor( resultCallback: ResultCallback, @ViewModelScope private val viewModelScope: CoroutineScope, + private val checkoutSessionRepository: CheckoutSessionRepository, + private val checkoutStateLoader: CheckoutStateLoader, + private val stateHolder: CheckoutControllerStateHolder, + internal val confirmationStateHolder: CheckoutConfirmationStateHolder, ) { - private val _checkoutSession = MutableStateFlow(null) - val checkoutSession: StateFlow = _checkoutSession.asStateFlow() + val checkoutSession: StateFlow + get() = stateHolder.checkoutSession suspend fun configure( checkoutSessionClientSecret: String, configuration: Configuration = Configuration(), ): kotlin.Result { - TODO("Not yet implemented") + val configurationState = configuration.build() + val sessionId = checkoutSessionClientSecret.substringBefore("_secret_") + + return checkoutSessionRepository.init( + sessionId = sessionId, + adaptivePricingAllowed = configurationState.adaptivePricingAllowed, + ).mapCatching { response -> + checkoutStateLoader.load( + CheckoutControllerState.defaultState( + configuration = configurationState, + checkoutSessionResponse = response, + ) + ) + } } suspend fun applyPromotionCode(promotionCode: String): kotlin.Result { diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerState.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerState.kt new file mode 100644 index 00000000000..555b998384e --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerState.kt @@ -0,0 +1,64 @@ +package com.stripe.android.checkout + +import android.graphics.Bitmap +import android.os.Parcelable +import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import kotlinx.parcelize.Parcelize +import java.util.UUID + +/** + * Internal state for [CheckoutController]. Unlike [InternalState] (used by [Checkout]), this holds + * the controller's own [CheckoutController.Configuration.State] directly, so the configuration + * doesn't have to be reconstructed and can be read back via [configuration]. + */ +@OptIn(CheckoutSessionPreview::class) +@Parcelize +internal data class CheckoutControllerState( + val key: String, + val configuration: CheckoutController.Configuration.State, + override val checkoutSessionResponse: CheckoutSessionResponse, + val flagImages: Map?, + override val shippingName: String?, + override val billingName: String?, + override val shippingPhoneNumber: String?, + override val billingPhoneNumber: String?, + override val shippingAddress: Address.State?, + override val billingAddress: Address.State?, + val integrationLaunched: Boolean, +) : Parcelable, CheckoutSessionData { + val initializationMode: PaymentElementLoader.InitializationMode.CheckoutSession + get() = PaymentElementLoader.InitializationMode.CheckoutSession( + instancesKey = key, + checkoutSessionResponse = checkoutSessionResponse, + ) + + fun asCheckoutSession(): CheckoutSession { + return checkoutSessionResponse.asCheckoutSession(flagImages) + } + + companion object { + /** + * Builds the initial state for a freshly configured checkout session: a new [key], the + * given [configuration] and [checkoutSessionResponse], and no resolved flag images or + * collected shipping/billing details yet. + */ + fun defaultState( + configuration: CheckoutController.Configuration.State, + checkoutSessionResponse: CheckoutSessionResponse, + ): CheckoutControllerState = CheckoutControllerState( + key = UUID.randomUUID().toString(), + configuration = configuration, + checkoutSessionResponse = checkoutSessionResponse, + flagImages = null, + shippingName = null, + billingName = null, + shippingPhoneNumber = null, + billingPhoneNumber = null, + shippingAddress = null, + billingAddress = null, + integrationLaunched = false, + ) + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerStateHolder.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerStateHolder.kt new file mode 100644 index 00000000000..979d4688e87 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutControllerStateHolder.kt @@ -0,0 +1,34 @@ +package com.stripe.android.checkout + +import androidx.lifecycle.SavedStateHandle +import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.uicore.utils.mapAsStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns [CheckoutController]'s [CheckoutControllerState], persisting it in [SavedStateHandle] so it + * survives process death, and derives the observable [checkoutSession] from it. Kept separate from + * the controller so [CheckoutStateLoader] can commit loaded state directly rather than reaching back + * into the controller. + */ +@OptIn(CheckoutSessionPreview::class) +@Singleton +internal class CheckoutControllerStateHolder @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) { + var state: CheckoutControllerState? + get() = savedStateHandle[STATE_KEY] + set(value) { + savedStateHandle[STATE_KEY] = value + } + + val checkoutSession: StateFlow = + savedStateHandle.getStateFlow(STATE_KEY, null) + .mapAsStateFlow { it?.asCheckoutSession() } + + companion object { + const val STATE_KEY = "CheckoutController_InternalState" + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutStateLoader.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutStateLoader.kt new file mode 100644 index 00000000000..7963d7c5952 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutStateLoader.kt @@ -0,0 +1,82 @@ +package com.stripe.android.checkout + +import com.stripe.android.checkout.injection.MerchantDisplayName +import com.stripe.android.common.model.asCommonConfiguration +import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder +import com.stripe.android.paymentelement.embedded.content.EmbeddedSelectionChooser +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import javax.inject.Inject + +/** + * Loads the payment element for a [CheckoutControllerState] and, on success, atomically commits it + * to the [stateHolder], [confirmationStateHolder], and [selectionHolder]. Extracted from + * [CheckoutController] so the load-and-commit flow can be unit tested in isolation from the + * controller. + */ +@OptIn(CheckoutSessionPreview::class) +internal class CheckoutStateLoader @Inject constructor( + @MerchantDisplayName private val merchantDisplayName: String, + private val flagImageResolver: FlagImageResolver, + private val paymentElementLoader: PaymentElementLoader, + private val selectionChooser: EmbeddedSelectionChooser, + private val selectionHolder: EmbeddedSelectionHolder, + private val confirmationStateHolder: CheckoutConfirmationStateHolder, + private val stateHolder: CheckoutControllerStateHolder, +) { + /** + * Loads the payment element for [state] and, on success, atomically commits the resolved state. + * Throws if the load fails, leaving the previously committed state untouched (the commits are + * the final step, so a failure short-circuits before any holder is mutated). + */ + suspend fun load(state: CheckoutControllerState) { + val response = state.checkoutSessionResponse + + val resolvedState = state.copy( + // [state] carries the previously resolved flag images forward (a mutation copies them + // from the committed state), so they're reused when the currencies haven't changed. + flagImages = flagImageResolver.resolve(response, cached = state.flagImages), + ) + + val baseConfig = EmbeddedPaymentElement.Configuration.Builder(merchantDisplayName) + .embeddedViewDisplaysMandateText( + resolvedState.configuration.paymentElementConfiguration.embeddedViewDisplaysMandateText + ) + .build() + + val embeddedConfig = CheckoutConfigurationMerger.EmbeddedConfiguration(baseConfig) + .forCheckoutSession(resolvedState) + + val loaderState = paymentElementLoader.load( + initializationMode = resolvedState.initializationMode, + integrationConfiguration = PaymentElementLoader.Configuration.Embedded( + isRowSelectionImmediateAction = false, + configuration = embeddedConfig, + ), + metadata = PaymentElementLoader.Metadata( + isReloadingAfterProcessDeath = false, + initializedViaCompose = false, + ), + ).getOrThrow() + + // Preserve the customer's existing selection across reloads when it's still valid, rather + // than blindly adopting the loader's recomputed selection (reuses the embedded logic). + val selection = selectionChooser.choose( + paymentMethodMetadata = loaderState.paymentMethodMetadata, + paymentMethods = loaderState.customer?.paymentMethods, + previousSelection = selectionHolder.selection.value, + newSelection = loaderState.paymentSelection, + newConfiguration = embeddedConfig.asCommonConfiguration(), + formSheetAction = embeddedConfig.formSheetAction, + ) + + stateHolder.state = resolvedState + confirmationStateHolder.state = CheckoutConfirmationStateHolder.State( + paymentMethodMetadata = loaderState.paymentMethodMetadata, + selection = selection, + configuration = embeddedConfig, + ) + selectionHolder.set(selection) + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/InternalState.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/InternalState.kt index 3f321c88c93..62f676523a0 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/InternalState.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/InternalState.kt @@ -13,16 +13,16 @@ import kotlinx.parcelize.Parcelize internal data class InternalState( val key: String, val configuration: Checkout.Configuration.State, - val checkoutSessionResponse: CheckoutSessionResponse, + override val checkoutSessionResponse: CheckoutSessionResponse, val flagImages: Map?, - val shippingName: String? = null, - val billingName: String? = null, - val shippingPhoneNumber: String? = null, - val billingPhoneNumber: String? = null, - val shippingAddress: Address.State? = null, - val billingAddress: Address.State? = null, + override val shippingName: String? = null, + override val billingName: String? = null, + override val shippingPhoneNumber: String? = null, + override val billingPhoneNumber: String? = null, + override val shippingAddress: Address.State? = null, + override val billingAddress: Address.State? = null, val integrationLaunched: Boolean = false, -) : Parcelable { +) : Parcelable, CheckoutSessionData { val initializationMode: PaymentElementLoader.InitializationMode.CheckoutSession get() = PaymentElementLoader.InitializationMode.CheckoutSession( instancesKey = key, @@ -35,7 +35,7 @@ internal data class InternalState( } @OptIn(CheckoutSessionPreview::class) -private fun CheckoutSessionResponse.asCheckoutSession( +internal fun CheckoutSessionResponse.asCheckoutSession( flagImages: Map?, ): CheckoutSession { return CheckoutSession( diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/injection/CheckoutControllerComponent.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/injection/CheckoutControllerComponent.kt index 5dc8922dc78..78346136122 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/injection/CheckoutControllerComponent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/injection/CheckoutControllerComponent.kt @@ -4,21 +4,102 @@ package com.stripe.android.checkout.injection import android.app.Application import androidx.lifecycle.SavedStateHandle +import com.stripe.android.cards.CardAccountRangeRepository +import com.stripe.android.cards.DefaultCardAccountRangeRepositoryFactory +import com.stripe.android.checkout.CheckoutConfirmationStateHolder import com.stripe.android.checkout.CheckoutController +import com.stripe.android.common.di.ElementsSessionClientParamsModule +import com.stripe.android.common.nfcscan.NfcScanningAvailabilityModule +import com.stripe.android.common.taptoadd.TapToAddConnectionModule +import com.stripe.android.core.injection.CoreCommonModule +import com.stripe.android.core.injection.CoroutineContextModule import com.stripe.android.core.injection.ViewModelScope +import com.stripe.android.core.networking.AnalyticsRequestFactory +import com.stripe.android.core.utils.DefaultDurationProvider +import com.stripe.android.core.utils.DurationProvider +import com.stripe.android.core.utils.RealUserFacingLogger +import com.stripe.android.core.utils.UserFacingLogger +import com.stripe.android.googlepaylauncher.injection.GooglePayLauncherModule +import com.stripe.android.link.account.LinkAccountHolder +import com.stripe.android.link.injection.PaymentsIntegrityModule +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata +import com.stripe.android.networking.PaymentAnalyticsRequestFactory +import com.stripe.android.networking.PaymentElementRequestSurfaceModule +import com.stripe.android.paymentelement.AnalyticEventCallback import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.paymentelement.ExperimentalAnalyticEventCallbackApi +import com.stripe.android.paymentelement.callbacks.PaymentElementCallbackIdentifier +import com.stripe.android.paymentelement.callbacks.PaymentElementCallbackReferences +import com.stripe.android.paymentelement.confirmation.ALLOWS_MANUAL_CONFIRMATION +import com.stripe.android.paymentelement.embedded.EmbeddedLinkExtrasModule +import com.stripe.android.paymentelement.embedded.InternalRowSelectionCallback +import com.stripe.android.paymentelement.embedded.content.DefaultEmbeddedSelectionChooser +import com.stripe.android.paymentelement.embedded.content.EmbeddedSelectionChooser +import com.stripe.android.payments.core.analytics.ErrorReporter +import com.stripe.android.payments.core.analytics.RealErrorReporter +import com.stripe.android.payments.core.injection.StripeRepositoryModule +import com.stripe.android.paymentsheet.DefaultPrefsRepository +import com.stripe.android.paymentsheet.PaymentOptionCardArtModule +import com.stripe.android.paymentsheet.PrefsRepository +import com.stripe.android.paymentsheet.analytics.DefaultEventReporter +import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.paymentsheet.analytics.LoadingEventReporter +import com.stripe.android.paymentsheet.cvcrecollection.CvcRecollectionHandler +import com.stripe.android.paymentsheet.cvcrecollection.CvcRecollectionHandlerImpl +import com.stripe.android.paymentsheet.injection.LinkHoldbackExposureModule +import com.stripe.android.paymentsheet.injection.PaymentMethodMessagePromotionsExperimentHandlerModule +import com.stripe.android.paymentsheet.repositories.CustomerApiRepository +import com.stripe.android.paymentsheet.repositories.CustomerRepository +import com.stripe.android.paymentsheet.repositories.DefaultSavedPaymentMethodRepository +import com.stripe.android.paymentsheet.repositories.ElementsSessionRepository +import com.stripe.android.paymentsheet.repositories.PaymentMethodMessagePromotionsHelperModule +import com.stripe.android.paymentsheet.repositories.RealElementsSessionRepository +import com.stripe.android.paymentsheet.repositories.SavedPaymentMethodRepository +import com.stripe.android.paymentsheet.state.CreateLinkState +import com.stripe.android.paymentsheet.state.DefaultAnalyticsMetadataFactory +import com.stripe.android.paymentsheet.state.DefaultCreateLinkState +import com.stripe.android.paymentsheet.state.DefaultLinkAccountStatusProvider +import com.stripe.android.paymentsheet.state.DefaultPaymentElementLoader +import com.stripe.android.paymentsheet.state.DefaultPaymentMethodFilter +import com.stripe.android.paymentsheet.state.DefaultRetrieveCustomerEmail +import com.stripe.android.paymentsheet.state.DefaultTapToAddAvailabilityFactory +import com.stripe.android.paymentsheet.state.LinkAccountStatusProvider +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import com.stripe.android.paymentsheet.state.PaymentMethodFilter +import com.stripe.android.paymentsheet.state.RetrieveCustomerEmail +import com.stripe.android.paymentsheet.state.TapToAddAvailabilityFactory +import com.stripe.android.paymentsheet.state.TapToAddConnectionStarterModule +import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import java.util.UUID +import javax.inject.Named import javax.inject.Singleton @Singleton @Component( modules = [ CheckoutControllerModule::class, + CheckoutModule::class, + CoreCommonModule::class, + CoroutineContextModule::class, + ElementsSessionClientParamsModule::class, + StripeRepositoryModule::class, + GooglePayLauncherModule::class, + TapToAddConnectionStarterModule::class, + TapToAddConnectionModule::class, + PaymentsIntegrityModule::class, + PaymentElementRequestSurfaceModule::class, + EmbeddedLinkExtrasModule::class, + LinkHoldbackExposureModule::class, + PaymentMethodMessagePromotionsHelperModule::class, + PaymentMethodMessagePromotionsExperimentHandlerModule::class, + NfcScanningAvailabilityModule::class, + PaymentOptionCardArtModule::class, ], ) internal interface CheckoutControllerComponent { @@ -34,12 +115,144 @@ internal interface CheckoutControllerComponent { } } +@Suppress("TooManyFunctions") @Module -internal object CheckoutControllerModule { - @Provides +internal interface CheckoutControllerModule { + @Binds + fun bindPaymentElementLoader(loader: DefaultPaymentElementLoader): PaymentElementLoader + + @Binds + fun bindsElementsSessionRepository(impl: RealElementsSessionRepository): ElementsSessionRepository + + @Binds + fun bindsTapToAddAvailabilityFactory(impl: DefaultTapToAddAvailabilityFactory): TapToAddAvailabilityFactory + + @Binds + fun bindsPaymentMethodFilter(impl: DefaultPaymentMethodFilter): PaymentMethodFilter + + @Binds + fun bindAnalyticsMetadataFactory( + implementation: DefaultAnalyticsMetadataFactory + ): DefaultPaymentElementLoader.AnalyticsMetadataFactory + + @Binds + fun bindsCreateLinkState(impl: DefaultCreateLinkState): CreateLinkState + + @Binds + fun bindRetrieveCustomerEmail(impl: DefaultRetrieveCustomerEmail): RetrieveCustomerEmail + + @Binds + fun bindsUserFacingLogger(impl: RealUserFacingLogger): UserFacingLogger + + @Binds + fun bindsLinkAccountStatusProvider(impl: DefaultLinkAccountStatusProvider): LinkAccountStatusProvider + + @Binds + fun bindsCardAccountRangeRepositoryFactory( + factory: DefaultCardAccountRangeRepositoryFactory + ): CardAccountRangeRepository.Factory + + @Binds + fun bindsPrefsRepositoryFactory(factory: DefaultPrefsRepository.Factory): PrefsRepository.Factory + + @Binds @Singleton - @ViewModelScope - fun provideViewModelScope(): CoroutineScope { - return CoroutineScope(Dispatchers.Main) + fun bindsEventReporter(eventReporter: DefaultEventReporter): EventReporter + + @Binds + @Singleton + fun bindsLoadingReporter(eventReporter: DefaultEventReporter): LoadingEventReporter + + @Binds + fun bindsErrorReporter(errorReporter: RealErrorReporter): ErrorReporter + + @Binds + fun bindsCustomerRepository(repository: CustomerApiRepository): CustomerRepository + + @Binds + fun bindsSavedPaymentMethodRepository( + repository: DefaultSavedPaymentMethodRepository, + ): SavedPaymentMethodRepository + + @Binds + fun bindsPaymentAnalyticsRequestFactory( + factory: PaymentAnalyticsRequestFactory + ): AnalyticsRequestFactory + + @Binds + fun bindsEmbeddedSelectionChooser(impl: DefaultEmbeddedSelectionChooser): EmbeddedSelectionChooser + + companion object { + private const val CALLBACK_IDENTIFIER_KEY = "CheckoutController_CallbackIdentifier" + + @Provides + @Singleton + @PaymentElementCallbackIdentifier + fun providePaymentElementCallbackIdentifier(savedStateHandle: SavedStateHandle): String { + return savedStateHandle.get(CALLBACK_IDENTIFIER_KEY) + ?: UUID.randomUUID().toString().also { savedStateHandle[CALLBACK_IDENTIFIER_KEY] = it } + } + + @Provides + @Singleton + fun providesLinkAccountHolder(savedStateHandle: SavedStateHandle): LinkAccountHolder { + return LinkAccountHolder(savedStateHandle) + } + + @Provides + @Singleton + @ViewModelScope + fun provideViewModelScope(): CoroutineScope { + return CoroutineScope(Dispatchers.Main) + } + + @Provides + fun provideDurationProvider(): DurationProvider { + return DefaultDurationProvider.instance + } + + @Provides + @MerchantDisplayName + fun provideMerchantDisplayName(application: Application): String { + return application.applicationInfo.loadLabel(application.packageManager).toString() + } + + @Provides + fun providesInternalRowSelectionCallback( + @PaymentElementCallbackIdentifier paymentElementCallbackIdentifier: String, + ): InternalRowSelectionCallback? { + return PaymentElementCallbackReferences[paymentElementCallbackIdentifier]?.rowSelectionCallback + } + + @Provides + @Singleton + @Named(ALLOWS_MANUAL_CONFIRMATION) + fun provideAllowsManualConfirmation(): Boolean = true + + @Provides + fun provideEventReporterMode(): EventReporter.Mode { + return EventReporter.Mode.Embedded + } + + @Provides + @Singleton + fun provideCvcRecollectionHandler(): CvcRecollectionHandler { + return CvcRecollectionHandlerImpl() + } + + @Provides + fun providePaymentMethodMetadata( + confirmationStateHolder: CheckoutConfirmationStateHolder, + ): PaymentMethodMetadata? { + return confirmationStateHolder.state?.paymentMethodMetadata + } + + @OptIn(ExperimentalAnalyticEventCallbackApi::class) + @Provides + fun providesAnalyticEventCallback( + @PaymentElementCallbackIdentifier paymentElementCallbackIdentifier: String, + ): AnalyticEventCallback? { + return PaymentElementCallbackReferences[paymentElementCallbackIdentifier]?.analyticEventCallback + } } } diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/injection/MerchantDisplayName.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/injection/MerchantDisplayName.kt new file mode 100644 index 00000000000..519ae7c37dd --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/injection/MerchantDisplayName.kt @@ -0,0 +1,6 @@ +package com.stripe.android.checkout.injection + +import javax.inject.Qualifier + +@Qualifier +internal annotation class MerchantDisplayName diff --git a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutConfirmationStateHolderTest.kt b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutConfirmationStateHolderTest.kt new file mode 100644 index 00000000000..cabaef136e5 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutConfirmationStateHolderTest.kt @@ -0,0 +1,105 @@ +package com.stripe.android.checkout + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadataFactory +import com.stripe.android.model.PaymentMethodFixtures +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder +import com.stripe.android.paymentsheet.model.PaymentSelection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +internal class CheckoutConfirmationStateHolderTest { + @Test + fun `selection holder changes update confirmation state selection`() = runScenario { + confirmationStateHolder.state = state(selection = PaymentSelection.GooglePay) + + selectionHolder.set(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + + assertThat(confirmationStateHolder.state?.selection) + .isEqualTo(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + } + + @Test + fun `selection holder changes are a no-op while state is null`() = runScenario { + // Before configure commits a state, the holder is null; a selection change must not + // materialize a state (nor throw), since state?.copy is a no-op on null. + selectionHolder.set(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + + assertThat(confirmationStateHolder.state).isNull() + } + + @Test + fun `state round-trips through the saved state handle`() = runScenario { + // Align the selection holder with the state so the recreated holder's init collector, which + // immediately re-emits the current selection, copies the same value and leaves state intact. + selectionHolder.set(PaymentSelection.GooglePay) + val state = state(selection = PaymentSelection.GooglePay) + confirmationStateHolder.state = state + + // A fresh holder over the same handle sees the persisted state (survives process death). + val recreated = CheckoutConfirmationStateHolder( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ) + assertThat(recreated.state).isEqualTo(state) + } + + @Test + fun `stateFlow emits the initial null and each committed state`() = runScenario { + confirmationStateHolder.stateFlow.test { + assertThat(awaitItem()).isNull() + + val committed = state(selection = PaymentSelection.GooglePay) + confirmationStateHolder.state = committed + assertThat(awaitItem()).isEqualTo(committed) + } + } + + @Test + fun `stateFlow emits the updated selection when the selection holder changes`() = runScenario { + confirmationStateHolder.state = state(selection = PaymentSelection.GooglePay) + + confirmationStateHolder.stateFlow.test { + assertThat(awaitItem()?.selection).isEqualTo(PaymentSelection.GooglePay) + + selectionHolder.set(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + assertThat(awaitItem()?.selection).isEqualTo(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + } + } + + private fun state(selection: PaymentSelection?) = CheckoutConfirmationStateHolder.State( + paymentMethodMetadata = PaymentMethodMetadataFactory.create(), + selection = selection, + configuration = EmbeddedPaymentElement.Configuration.Builder("Example, Inc.").build(), + ) + + private fun runScenario( + block: suspend Scenario.() -> Unit, + ) = runTest { + val savedStateHandle = SavedStateHandle() + val selectionHolder = EmbeddedSelectionHolder(savedStateHandle) + val confirmationStateHolder = CheckoutConfirmationStateHolder( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ) + + Scenario( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + confirmationStateHolder = confirmationStateHolder, + ).block() + } + + private class Scenario( + val savedStateHandle: SavedStateHandle, + val selectionHolder: EmbeddedSelectionHolder, + val confirmationStateHolder: CheckoutConfirmationStateHolder, + ) +} diff --git a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutControllerTest.kt b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutControllerTest.kt new file mode 100644 index 00000000000..00c875ee124 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutControllerTest.kt @@ -0,0 +1,275 @@ +package com.stripe.android.checkout + +import android.app.Application +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID +import com.stripe.android.checkouttesting.checkoutInit +import com.stripe.android.networktesting.NetworkRule +import com.stripe.android.networktesting.RequestMatchers.bodyPart +import com.stripe.android.networktesting.testBodyFromFile +import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.testing.PaymentConfigurationTestRule +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(CheckoutSessionPreview::class) +@RunWith(RobolectricTestRunner::class) +internal class CheckoutControllerTest { + + private val applicationContext = ApplicationProvider.getApplicationContext() + private val networkRule = NetworkRule() + + // The controller defaults the merchant display name to the host app's label, never the checkout + // session. Mirroring that resolution here keeps the assertion decoupled from Robolectric's + // package naming while still failing if the code regresses to a session-sourced value. + private val expectedMerchantDisplayName: String + get() = applicationContext.applicationInfo + .loadLabel(applicationContext.packageManager) + .toString() + + @get:Rule + val ruleChain: RuleChain = RuleChain + .outerRule(networkRule) + .around(PaymentConfigurationTestRule(applicationContext)) + + @Test + fun `configure returns success`() = runConfigureScenario { + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `configure emits checkoutSession with id from response`() = runConfigureScenario { + result.getOrThrow() + assertThat(controller.checkoutSession.value?.id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) + } + + @Test + fun `checkoutSession flow transitions from null to loaded session`() = runTest { + networkRule.defaultInit() + val controller = createController() + + controller.checkoutSession.test { + assertThat(awaitItem()).isNull() + + controller.configure(DEFAULT_CLIENT_SECRET).getOrThrow() + + assertThat(awaitItem()?.id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) + } + } + + @Test + fun `configure sends adaptive_pricing allowed false by default`() = runConfigureScenario( + networkSetup = { + networkRule.checkoutInit( + bodyPart("adaptive_pricing[allowed]", "false"), + responseFactory = ::successResponse, + ) + }, + ) { + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `configure sends adaptive_pricing allowed true when configured`() = runConfigureScenario( + configuration = CheckoutController.Configuration().adaptivePricingAllowed(true), + networkSetup = { + networkRule.checkoutInit( + bodyPart("adaptive_pricing[allowed]", "true"), + responseFactory = ::successResponse, + ) + }, + ) { + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `configure parses session id from client secret`() = runConfigureScenario( + clientSecret = "cs_test_custom_secret_example", + networkSetup = { + // The request must hit the path for the parsed session id, not the response's id. + networkRule.checkoutInit(sessionId = "cs_test_custom", responseFactory = ::successResponse) + }, + ) { + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `configure populates confirmationState with payment method metadata`() = runConfigureScenario { + result.getOrThrow() + val confirmationState = controller.confirmationStateHolder.state + assertThat(confirmationState).isNotNull() + assertThat(confirmationState!!.paymentMethodMetadata).isNotNull() + } + + @Test + fun `configure uses app name as merchant display name, not checkout session data`() = + runConfigureScenario { + result.getOrThrow() + assertThat(controller.confirmationStateHolder.state?.configuration?.merchantDisplayName) + .isEqualTo(expectedMerchantDisplayName) + } + + @Test + fun `configure propagates embeddedViewDisplaysMandateText from payment element configuration`() = + runConfigureScenario( + configuration = CheckoutController.Configuration().paymentElement( + PaymentElement.Configuration().embeddedViewDisplaysMandateText(false) + ), + ) { + result.getOrThrow() + assertThat(controller.confirmationStateHolder.state?.configuration?.embeddedViewDisplaysMandateText) + .isFalse() + } + + @Test + fun `configure returns failure when network request fails`() = runConfigureScenario( + networkSetup = { + networkRule.checkoutInit { response -> + response.setResponseCode(500) + response.setBody("""{"error": {"message": "Internal server error"}}""") + } + }, + ) { + assertThat(result.isFailure).isTrue() + } + + @Test + fun `configure does not emit checkoutSession when network request fails`() = runConfigureScenario( + networkSetup = { + networkRule.checkoutInit { response -> + response.setResponseCode(500) + response.setBody("""{"error": {"message": "Internal server error"}}""") + } + }, + ) { + assertThat(result.isFailure).isTrue() + assertThat(controller.checkoutSession.value).isNull() + assertThat(controller.confirmationStateHolder.state).isNull() + } + + @Test + fun `configure returns failure when response has no elements session`() = runConfigureScenario( + networkSetup = { + networkRule.checkoutInit { response -> + // Identical to the success fixture (customer_email present) except elements_session + // is removed, so the failure is pinned to the missing session and nothing else. + response.testBodyFromFile("checkout-session-init.json") { json -> + json.put("customer_email", "checkout@example.com") + json.remove("elements_session") + } + } + }, + ) { + assertThat(result.isFailure).isTrue() + assertThat(controller.confirmationStateHolder.state).isNull() + } + + @Test + fun `checkoutSession is null before configure`() = runTest { + val controller = createController() + assertThat(controller.checkoutSession.value).isNull() + assertThat(controller.confirmationStateHolder.state).isNull() + } + + @Test + fun `checkoutSession is restored from savedStateHandle after recreation`() = runTest { + networkRule.defaultInit() + val savedStateHandle = SavedStateHandle() + val controller = createController(savedStateHandle) + controller.configure(DEFAULT_CLIENT_SECRET).getOrThrow() + + // Simulate process death: a new controller built from the same saved state. + val recreated = createController(savedStateHandle) + + assertThat(recreated.checkoutSession.value?.id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) + } + + @Test + fun `confirmationState is restored from savedStateHandle after recreation`() = runTest { + networkRule.defaultInit() + val savedStateHandle = SavedStateHandle() + val controller = createController(savedStateHandle) + controller.configure(DEFAULT_CLIENT_SECRET).getOrThrow() + + val recreated = createController(savedStateHandle) + + val confirmationState = recreated.confirmationStateHolder.state + assertThat(confirmationState).isNotNull() + assertThat(confirmationState!!.configuration.merchantDisplayName) + .isEqualTo(expectedMerchantDisplayName) + } + + @Test + fun `callback identifier is generated and stored when absent`() = runTest { + val savedStateHandle = SavedStateHandle() + createController(savedStateHandle) + + assertThat(savedStateHandle.get(CALLBACK_IDENTIFIER_KEY)).isNotNull() + } + + @Test + fun `callback identifier is reused from savedStateHandle when present`() = runTest { + val savedStateHandle = SavedStateHandle() + savedStateHandle[CALLBACK_IDENTIFIER_KEY] = "existing_identifier" + + createController(savedStateHandle) + + assertThat(savedStateHandle.get(CALLBACK_IDENTIFIER_KEY)) + .isEqualTo("existing_identifier") + } + + private fun NetworkRule.defaultInit() { + checkoutInit(responseFactory = ::successResponse) + } + + // The merged loader configuration requires a billing email, which `configure` sources from the + // session's customer_email. The base fixture omits it, so inject one for the success paths. + // Link is disabled so the loader doesn't fire a consumer session lookup that's unrelated to + // what these tests verify. + private fun successResponse(response: MockResponse) { + response.testBodyFromFile("checkout-session-init.json") { json -> + json.put("customer_email", "checkout@example.com") + json.getJSONObject("elements_session").remove("link_settings") + } + } + + private fun createController( + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): CheckoutController { + return CheckoutController.Builder( + application = applicationContext, + savedStateHandle = savedStateHandle, + resultCallback = {}, + ).build() + } + + private fun runConfigureScenario( + clientSecret: String = DEFAULT_CLIENT_SECRET, + configuration: CheckoutController.Configuration = CheckoutController.Configuration(), + networkSetup: () -> Unit = { networkRule.defaultInit() }, + block: suspend Scenario.() -> Unit, + ) = runTest { + networkSetup() + val controller = createController() + val result = controller.configure(clientSecret, configuration) + block(Scenario(controller, result)) + } + + private class Scenario( + val controller: CheckoutController, + val result: Result, + ) + + private companion object { + const val DEFAULT_CLIENT_SECRET = "${DEFAULT_CHECKOUT_SESSION_ID}_secret_example" + const val CALLBACK_IDENTIFIER_KEY = "CheckoutController_CallbackIdentifier" + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutStateLoaderTest.kt b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutStateLoaderTest.kt new file mode 100644 index 00000000000..aacd260950f --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutStateLoaderTest.kt @@ -0,0 +1,291 @@ +package com.stripe.android.checkout + +import android.app.Application +import android.graphics.Bitmap +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID +import com.stripe.android.common.model.CommonConfiguration +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata +import com.stripe.android.model.PaymentMethod +import com.stripe.android.model.PaymentMethodFixtures +import com.stripe.android.networking.PaymentAnalyticsRequestFactory +import com.stripe.android.paymentelement.CheckoutSessionPreview +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.embedded.EmbeddedFormHelperFactory +import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder +import com.stripe.android.paymentelement.embedded.content.DefaultEmbeddedSelectionChooser +import com.stripe.android.paymentelement.embedded.content.EmbeddedSelectionChooser +import com.stripe.android.paymentsheet.analytics.FakeEventReporter +import com.stripe.android.paymentsheet.model.PaymentSelection +import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse +import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponseFactory +import com.stripe.android.testing.FakeAnalyticsRequestExecutor +import com.stripe.android.testing.FakeStripeImageLoader +import com.stripe.android.utils.FakeLinkConfigurationCoordinator +import com.stripe.android.utils.FakePaymentElementLoader +import com.stripe.android.utils.NullCardAccountRangeRepositoryFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@OptIn(CheckoutSessionPreview::class) +@RunWith(RobolectricTestRunner::class) +internal class CheckoutStateLoaderTest { + + @Test + fun `load commits confirmation state with payment method metadata`() = runScenario { + loader.load(checkoutControllerState()) + + assertThat(confirmationStateHolder.state?.paymentMethodMetadata).isNotNull() + } + + @Test + fun `load uses the provided merchant display name`() = runScenario( + merchantDisplayName = "Acme Corp", + ) { + loader.load(checkoutControllerState()) + + assertThat(confirmationStateHolder.state?.configuration?.merchantDisplayName).isEqualTo("Acme Corp") + } + + @Test + fun `load sources the billing email from the checkout session customer email`() = runScenario { + val response = CheckoutSessionResponseFactory.create(customerEmail = "checkout@example.com") + + loader.load(checkoutControllerState(checkoutSessionResponse = response)) + + assertThat(confirmationStateHolder.state?.configuration?.defaultBillingDetails?.email) + .isEqualTo("checkout@example.com") + } + + @Test + fun `load propagates embeddedViewDisplaysMandateText from the payment element configuration`() = + runScenario { + val configuration = CheckoutController.Configuration() + .paymentElement(PaymentElement.Configuration().embeddedViewDisplaysMandateText(false)) + .build() + + loader.load(checkoutControllerState(configuration = configuration)) + + assertThat(confirmationStateHolder.state?.configuration?.embeddedViewDisplaysMandateText) + .isFalse() + } + + @Test + fun `load routes the selection through the chooser`() = runScenario( + loaderSelection = PaymentSelection.GooglePay, + chosenSelection = PaymentMethodFixtures.CARD_PAYMENT_SELECTION, + ) { + // The customer's prior selection is what the chooser must be offered as the previous value. + selectionHolder.set(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + + loader.load(checkoutControllerState()) + + // The committed state adopts whatever the chooser returned, not the loader's recomputed + // selection, and the selection holder is updated to match. + assertThat(confirmationStateHolder.state?.selection) + .isEqualTo(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + assertThat(selectionHolder.selection.value) + .isEqualTo(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + val call = chooser.lastCall + assertThat(call?.previousSelection).isEqualTo(PaymentMethodFixtures.CARD_PAYMENT_SELECTION) + assertThat(call?.newSelection).isEqualTo(PaymentSelection.GooglePay) + } + + @Test + fun `load preserves a non-default selection across a mutation`() = runScenario( + // The loader would recompute a card selection, but the customer's Google Pay pick must win. + loaderSelection = PaymentMethodFixtures.CARD_PAYMENT_SELECTION, + isGooglePayAvailable = true, + selectionChooser = { savedStateHandle -> + DefaultEmbeddedSelectionChooser( + savedStateHandle = savedStateHandle, + formHelperFactory = EmbeddedFormHelperFactory( + linkConfigurationCoordinator = FakeLinkConfigurationCoordinator(), + embeddedSelectionHolder = EmbeddedSelectionHolder(savedStateHandle), + cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory, + savedStateHandle = savedStateHandle, + ), + eventReporter = FakeEventReporter(), + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + internalRowSelectionCallback = { null }, + ) + }, + ) { + // Initial load seeds the chooser's stored previous configuration. + loader.load(checkoutControllerState()) + + // The customer picks Google Pay after the initial load. + selectionHolder.set(PaymentSelection.GooglePay) + + // A mutation reloads with the same configuration, so the chooser keeps the customer's + // selection rather than adopting the loader's recomputed one. + loader.load(requireNotNull(stateHolder.state)) + + assertThat(confirmationStateHolder.state?.selection).isEqualTo(PaymentSelection.GooglePay) + assertThat(selectionHolder.selection.value).isEqualTo(PaymentSelection.GooglePay) + } + + @Test + fun `load commits state that exposes the checkout session`() = runScenario { + loader.load(checkoutControllerState()) + + assertThat(stateHolder.checkoutSession.value?.id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) + // No adaptive pricing in the response, so no flag images are resolved. + assertThat(stateHolder.state?.flagImages).isNull() + } + + @Test + fun `load throws and commits nothing when the payment element loader fails`() = runScenario( + shouldFail = true, + ) { + assertFailsWith { + loader.load(checkoutControllerState()) + } + + assertThat(stateHolder.state).isNull() + assertThat(confirmationStateHolder.state).isNull() + } + + @Test + fun `load reuses cached flag images when the currencies are unchanged`() = runScenario { + val response = CheckoutSessionResponseFactory.create(adaptivePricingInfo = adaptivePricingInfo()) + + loader.load(checkoutControllerState(checkoutSessionResponse = response)) + + // Both currency flags are downloaded on the initial load. + imageLoader.awaitLoadCall() + imageLoader.awaitLoadCall() + + // A mutation reloads with the previously resolved images carried forward (a copy of the + // committed state) and the same currencies, so the cache is reused and nothing re-downloads. + val reloaded = requireNotNull(stateHolder.state).copy(checkoutSessionResponse = response) + loader.load(reloaded) + + imageLoader.ensureAllEventsConsumed() + } + + private fun checkoutControllerState( + configuration: CheckoutController.Configuration.State = CheckoutController.Configuration().build(), + checkoutSessionResponse: CheckoutSessionResponse = CheckoutSessionResponseFactory.create(), + ) = CheckoutControllerState.defaultState( + configuration = configuration, + checkoutSessionResponse = checkoutSessionResponse, + ) + + // Adaptive pricing (usd → eur) drives flag image resolution during load. + private fun adaptivePricingInfo() = CheckoutSessionResponse.AdaptivePricingInfo( + activePresentmentCurrency = "eur", + integrationAmount = 5099, + integrationCurrency = "usd", + localCurrencyOptions = listOf( + CheckoutSessionResponse.LocalCurrencyOption( + amount = 4594, + conversionMarkupBps = 400, + currency = "eur", + presentmentExchangeRate = "0.900961", + ), + ), + ) + + private fun runScenario( + merchantDisplayName: String = "Example, Inc.", + loaderSelection: PaymentSelection? = null, + chosenSelection: PaymentSelection? = null, + shouldFail: Boolean = false, + isGooglePayAvailable: Boolean = false, + // When null, a RecordingSelectionChooser is used. Pass a factory to exercise the real + // DefaultEmbeddedSelectionChooser (it needs the shared SavedStateHandle to track state). + selectionChooser: ((SavedStateHandle) -> EmbeddedSelectionChooser)? = null, + block: suspend Scenario.() -> Unit, + ) = runTest { + val application = ApplicationProvider.getApplicationContext() + val imageLoader = FakeStripeImageLoader( + loadResult = Result.success(Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888)), + ) + val flagImageResolver = FlagImageResolver( + flagImageRepository = FlagImageRepository(imageLoader = imageLoader, displayDensity = 3f), + analyticsRequestExecutor = FakeAnalyticsRequestExecutor(), + paymentAnalyticsRequestFactory = PaymentAnalyticsRequestFactory( + context = application, + publishableKey = "pk_test_123", + ), + ) + // All holders share one SavedStateHandle, mirroring the singleton graph in production. + val savedStateHandle = SavedStateHandle() + val selectionHolder = EmbeddedSelectionHolder(savedStateHandle) + val confirmationStateHolder = CheckoutConfirmationStateHolder( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ) + val stateHolder = CheckoutControllerStateHolder(savedStateHandle) + val recordingChooser = RecordingSelectionChooser(chosenSelection) + val chooser = selectionChooser?.invoke(savedStateHandle) ?: recordingChooser + val loader = CheckoutStateLoader( + merchantDisplayName = merchantDisplayName, + flagImageResolver = flagImageResolver, + paymentElementLoader = FakePaymentElementLoader( + paymentSelection = loaderSelection, + shouldFail = shouldFail, + isGooglePayAvailable = isGooglePayAvailable, + ), + selectionChooser = chooser, + selectionHolder = selectionHolder, + confirmationStateHolder = confirmationStateHolder, + stateHolder = stateHolder, + ) + + Scenario( + loader = loader, + stateHolder = stateHolder, + confirmationStateHolder = confirmationStateHolder, + selectionHolder = selectionHolder, + chooser = recordingChooser, + imageLoader = imageLoader, + ).block() + + imageLoader.ensureAllEventsConsumed() + } + + private class Scenario( + val loader: CheckoutStateLoader, + val stateHolder: CheckoutControllerStateHolder, + val confirmationStateHolder: CheckoutConfirmationStateHolder, + val selectionHolder: EmbeddedSelectionHolder, + val chooser: RecordingSelectionChooser, + val imageLoader: FakeStripeImageLoader, + ) + + // Records the arguments of the most recent choose() call and returns a preconfigured selection, + // so tests can verify the loader threads the holder's previous selection and the loader's new + // selection into the chooser. + private class RecordingSelectionChooser( + private val result: PaymentSelection?, + ) : EmbeddedSelectionChooser { + var lastCall: Call? = null + + override fun choose( + paymentMethodMetadata: PaymentMethodMetadata, + paymentMethods: List?, + previousSelection: PaymentSelection?, + newSelection: PaymentSelection?, + newConfiguration: CommonConfiguration, + formSheetAction: EmbeddedPaymentElement.FormSheetAction, + ): PaymentSelection? { + lastCall = Call(previousSelection = previousSelection, newSelection = newSelection) + return result + } + + data class Call( + val previousSelection: PaymentSelection?, + val newSelection: PaymentSelection?, + ) + } +}