Repository: stripe/stripe-android
Pull request #10586: Add payment form completion analytic event to EventReporter
Author: login: cttsai-stripe
Labels: name: analytic-event-framework
Created: 2025-04-08T00:48:37Z | Merged: 2025-04-11T19:59:24Z

# Summary
<!-- Simple summary of what was changed. -->
# Motivation
<!-- Why are you making this change? If it's for fixing a bug, if possible, please include a code snippet or example project that demonstrates the issue. -->
We do not have a event indicating the user completed all required payment form fields

# Testing
<!-- How was the code tested? Be as specific as possible. -->
- [ ] Added tests
- [x] Modified tests
- [ ] Manually verified

# Screenshots
| Before  | After |
| ------------- | ------------- |
| *before screenshot*  | *after screenshot* |

# Changelog
<!-- Is this a notable change that affects users? If so, add a line to `CHANGELOG.md` and prefix the line with one of the following:
    - [Added] for new features.
    - [Changed] for changes in existing functionality.
    - [Deprecated] for soon-to-be removed features.
    - [Removed] for now removed features.
    - [Fixed] for any bug fixes.
    - [Security] in case of vulnerabilities.
-->



Diff:
diff --git a/paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt b/paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
index e592c9821d5..bfd2c8172d1 100644
--- a/paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
+++ b/paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
@@ -102,6 +102,7 @@ internal class EmbeddedPaymentElementAnalyticsTest {
 
         embeddedContentPage.clickOnLpm("card")
         formPage.fillOutCardDetails()
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.CompletedPaymentMethodForm("card"))
 
         networkRule.enqueue(
             method("POST"),
diff --git a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetAnalyticsTest.kt b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetAnalyticsTest.kt
index 82e7a7434cc..7d2a2390d3c 100644
--- a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetAnalyticsTest.kt
+++ b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetAnalyticsTest.kt
@@ -104,6 +104,7 @@ internal class PaymentSheetAnalyticsTest {
         validateAnalyticsRequest(eventName = "mc_card_number_completed")
 
         page.fillOutCardDetails()
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.CompletedPaymentMethodForm("card"))
 
         networkRule.enqueue(
             host("api.stripe.com"),
@@ -171,6 +172,7 @@ internal class PaymentSheetAnalyticsTest {
         validateAnalyticsRequest(eventName = "mc_card_number_completed")
 
         page.fillOutCardDetails()
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.CompletedPaymentMethodForm("card"))
 
         networkRule.enqueue(
             host("api.stripe.com"),
@@ -234,6 +236,7 @@ internal class PaymentSheetAnalyticsTest {
 
         page.clickOnLpm("card", forVerticalMode = true)
         page.fillOutCardDetails()
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.CompletedPaymentMethodForm("card"))
 
         networkRule.enqueue(
             host("api.stripe.com"),
@@ -264,6 +267,7 @@ internal class PaymentSheetAnalyticsTest {
     @Test
     fun testSuccessfulCardPaymentInFlowControllerInVerticalMode() = runFlowControllerTest(
         networkRule = networkRule,
+        analyticEventCallback = analyticEventRule,
         resultCallback = ::assertCompleted,
     ) { testContext ->
         networkRule.enqueue(
@@ -291,6 +295,7 @@ internal class PaymentSheetAnalyticsTest {
                 }
             )
         }
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.PresentedSheet())
 
         validateAnalyticsRequest(eventName = "stripe_android.card_metadata_pk_available")
         validateAnalyticsRequest(eventName = "mc_carousel_payment_method_tapped")
@@ -300,6 +305,7 @@ internal class PaymentSheetAnalyticsTest {
 
         page.clickOnLpm("card", forVerticalMode = true)
         page.fillOutCardDetails()
+        analyticEventRule.assertMatchesExpectedEvent(AnalyticEvent.CompletedPaymentMethodForm("card"))
 
         networkRule.enqueue(
             host("api.stripe.com"),
diff --git a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt
index dbaa27560e3..629d1cba60b 100644
--- a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt
@@ -50,7 +50,7 @@ internal class LinkActivityViewModel @Inject constructor(
     val eventReporter: EventReporter,
     private val linkConfiguration: LinkConfiguration,
     private val linkAttestationCheck: LinkAttestationCheck,
-    private val savedStateHandle: SavedStateHandle,
+    val savedStateHandle: SavedStateHandle,
     private val startWithVerificationDialog: Boolean
 ) : ViewModel(), DefaultLifecycleObserver {
     val confirmationHandler = confirmationHandlerFactory.create(viewModelScope)
diff --git a/paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt b/paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
index ba0bd71dd98..12cf99ab4f4 100644
--- a/paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
@@ -21,6 +21,7 @@ import com.stripe.android.paymentelement.confirmation.injection.DefaultConfirmat
 import com.stripe.android.paymentelement.confirmation.link.LinkPassthroughConfirmationModule
 import com.stripe.android.payments.core.analytics.ErrorReporter
 import com.stripe.android.payments.core.injection.STATUS_BAR_COLOR
+import com.stripe.android.paymentsheet.analytics.EventReporter
 import com.stripe.android.ui.core.di.CardScanModule
 import dagger.BindsInstance
 import dagger.Component
@@ -54,6 +55,7 @@ internal interface NativeLinkComponent {
     val linkAuth: LinkAuth
     val savedStateHandle: SavedStateHandle
     val viewModel: LinkActivityViewModel
+    val eventReporter: EventReporter
 
     @Component.Builder
     interface Builder {
diff --git a/paymentsheet/src/main/java/com/stripe/android/link/ui/paymentmenthod/PaymentMethodViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/link/ui/paymentmenthod/PaymentMethodViewModel.kt
index 9479b0469be..b702f654bee 100644
--- a/paymentsheet/src/main/java/com/stripe/android/link/ui/paymentmenthod/PaymentMethodViewModel.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/link/ui/paymentmenthod/PaymentMethodViewModel.kt
@@ -54,6 +54,10 @@ internal class PaymentMethodViewModel @Inject constructor(
             formValues = formValues,
             selectedPaymentMethodCode = PaymentMethod.Type.Card.code
         )
+        formHelper.onFormFieldValuesChanged(
+            formValues = formValues,
+            selectedPaymentMethodCode = PaymentMethod.Type.Card.code
+        )
         _state.update {
             it.copy(
                 paymentMethodCreateParams = paymentMethodCreateParams,
@@ -160,6 +164,8 @@ internal class PaymentMethodViewModel @Inject constructor(
                             paymentMethodMetadata = PaymentMethodMetadata.createForNativeLink(
                                 configuration = parentComponent.configuration,
                             ),
+                            eventReporter = parentComponent.eventReporter,
+                            savedStateHandle = parentComponent.viewModel.savedStateHandle
                         ),
                         logger = parentComponent.logger,
                         dismissWithResult = dismissWithResult
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
index 1765feb8f40..c24354546ed 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
@@ -1,5 +1,6 @@
 package com.stripe.android.paymentelement.embedded
 
+import androidx.lifecycle.SavedStateHandle
 import com.stripe.android.cards.CardAccountRangeRepository
 import com.stripe.android.link.LinkConfigurationCoordinator
 import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata
@@ -7,6 +8,7 @@ import com.stripe.android.paymentsheet.DefaultFormHelper
 import com.stripe.android.paymentsheet.FormHelper
 import com.stripe.android.paymentsheet.LinkInlineHandler
 import com.stripe.android.paymentsheet.NewPaymentOptionSelection
+import com.stripe.android.paymentsheet.analytics.EventReporter
 import com.stripe.android.paymentsheet.model.PaymentSelection
 import com.stripe.android.ui.core.elements.FORM_ELEMENT_SET_DEFAULT_MATCHES_SAVE_FOR_FUTURE_DEFAULT_VALUE
 import kotlinx.coroutines.CoroutineScope
@@ -16,11 +18,13 @@ internal class EmbeddedFormHelperFactory @Inject constructor(
     private val linkConfigurationCoordinator: LinkConfigurationCoordinator,
     private val embeddedSelectionHolder: EmbeddedSelectionHolder,
     private val cardAccountRangeRepositoryFactory: CardAccountRangeRepository.Factory,
+    private val savedStateHandle: SavedStateHandle,
 ) {
     fun create(
         coroutineScope: CoroutineScope,
         paymentMethodMetadata: PaymentMethodMetadata,
-        selectionUpdater: (PaymentSelection?) -> Unit
+        eventReporter: EventReporter,
+        selectionUpdater: (PaymentSelection?) -> Unit,
     ): FormHelper {
         return DefaultFormHelper(
             coroutineScope = coroutineScope,
@@ -44,6 +48,8 @@ internal class EmbeddedFormHelperFactory @Inject constructor(
             selectionUpdater = selectionUpdater,
             linkConfigurationCoordinator = linkConfigurationCoordinator,
             setAsDefaultMatchesSaveForFutureUse = FORM_ELEMENT_SET_DEFAULT_MATCHES_SAVE_FOR_FUTURE_DEFAULT_VALUE,
+            eventReporter = eventReporter,
+            savedStateHandle = savedStateHandle,
         )
     }
 }
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedContentHelper.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedContentHelper.kt
index be5886775e9..3937f5c3ee4 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedContentHelper.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedContentHelper.kt
@@ -132,7 +132,8 @@ internal class DefaultEmbeddedContentHelper @Inject constructor(
         val formHelper = embeddedFormHelperFactory.create(
             coroutineScope = coroutineScope,
             paymentMethodMetadata = paymentMethodMetadata,
-            selectionUpdater = ::setSelection
+            eventReporter = eventReporter,
+            selectionUpdater = ::setSelection,
         )
         val savedPaymentMethodMutator = createSavedPaymentMethodMutator(
             coroutineScope = coroutineScope,
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
index 51d12cf8c8b..59e80b3a288 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
@@ -11,6 +11,7 @@ import com.stripe.android.model.PaymentMethod
 import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi
 import com.stripe.android.paymentelement.embedded.EmbeddedFormHelperFactory
 import com.stripe.android.paymentsheet.FormHelper
+import com.stripe.android.paymentsheet.analytics.EventReporter
 import com.stripe.android.paymentsheet.model.PaymentSelection
 import com.stripe.android.paymentsheet.model.paymentMethodType
 import kotlinx.coroutines.CoroutineScope
@@ -29,6 +30,7 @@ internal fun interface EmbeddedSelectionChooser {
 internal class DefaultEmbeddedSelectionChooser @Inject constructor(
     private val savedStateHandle: MainThreadSavedStateHandle,
     private val formHelperFactory: EmbeddedFormHelperFactory,
+    private val eventReporter: EventReporter,
     @ViewModelScope private val coroutineScope: CoroutineScope,
 ) : EmbeddedSelectionChooser {
     private var previousConfiguration: CommonConfiguration?
@@ -95,7 +97,11 @@ internal class DefaultEmbeddedSelectionChooser @Inject constructor(
         previousSelection: PaymentSelection.New,
         paymentMethodMetadata: PaymentMethodMetadata,
     ): Boolean {
-        val newFormType = formHelperFactory.create(coroutineScope, paymentMethodMetadata) {}
+        val newFormType = formHelperFactory.create(
+            coroutineScope = coroutineScope,
+            paymentMethodMetadata = paymentMethodMetadata,
+            eventReporter = eventReporter,
+        ) {}
             .formTypeForCode(previousSelection.paymentMethodType)
         return newFormType != FormHelper.FormType.UserInteractionRequired
     }
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/EmbeddedFormInteractorFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/EmbeddedFormInteractorFactory.kt
index 4730cc11fad..d0d14a77fc5 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/EmbeddedFormInteractorFactory.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/EmbeddedFormInteractorFactory.kt
@@ -28,6 +28,7 @@ internal class EmbeddedFormInteractorFactory @Inject constructor(
         val formHelper = embeddedFormHelperFactory.create(
             coroutineScope = viewModelScope,
             paymentMethodMetadata = paymentMethodMetadata,
+            eventReporter = eventReporter,
             selectionUpdater = {
                 embeddedSelectionHolder.set(it)
             }
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
index 93a0e8aee47..244f8504a4f 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
@@ -1,5 +1,6 @@
 package com.stripe.android.paymentsheet
 
+import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
 import com.stripe.android.cards.CardAccountRangeRepository
 import com.stripe.android.link.LinkConfigurationCoordinator
@@ -10,9 +11,11 @@ import com.stripe.android.model.PaymentMethod
 import com.stripe.android.model.PaymentMethodCode
 import com.stripe.android.model.PaymentMethodCreateParams
 import com.stripe.android.paymentsheet.FormHelper.FormType
+import com.stripe.android.paymentsheet.analytics.EventReporter
 import com.stripe.android.paymentsheet.forms.FormArgumentsFactory
 import com.stripe.android.paymentsheet.forms.FormFieldValues
 import com.stripe.android.paymentsheet.model.PaymentSelection
+import com.stripe.android.paymentsheet.model.paymentMethodType
 import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments
 import com.stripe.android.paymentsheet.ui.transformToPaymentMethodCreateParams
 import com.stripe.android.paymentsheet.ui.transformToPaymentSelection
@@ -34,8 +37,11 @@ internal class DefaultFormHelper(
     private val selectionUpdater: (PaymentSelection?) -> Unit,
     private val linkConfigurationCoordinator: LinkConfigurationCoordinator?,
     private val setAsDefaultMatchesSaveForFutureUse: Boolean,
+    private val eventReporter: EventReporter,
+    private val savedStateHandle: SavedStateHandle,
 ) : FormHelper {
     companion object {
+        internal const val PREVIOUSLY_COMPLETED_PAYMENT_FORM = "previously_completed_payment_form"
         fun create(
             viewModel: BaseSheetViewModel,
             paymentMethodMetadata: PaymentMethodMetadata,
@@ -54,6 +60,8 @@ internal class DefaultFormHelper(
                     viewModel.updateSelection(it)
                 },
                 setAsDefaultMatchesSaveForFutureUse = viewModel.customerStateHolder.paymentMethods.value.isEmpty(),
+                eventReporter = viewModel.eventReporter,
+                savedStateHandle = viewModel.savedStateHandle,
             )
         }
 
@@ -61,6 +69,8 @@ internal class DefaultFormHelper(
             coroutineScope: CoroutineScope,
             cardAccountRangeRepositoryFactory: CardAccountRangeRepository.Factory,
             paymentMethodMetadata: PaymentMethodMetadata,
+            eventReporter: EventReporter,
+            savedStateHandle: SavedStateHandle,
         ): FormHelper {
             return DefaultFormHelper(
                 coroutineScope = coroutineScope,
@@ -71,6 +81,8 @@ internal class DefaultFormHelper(
                 linkConfigurationCoordinator = null,
                 selectionUpdater = {},
                 setAsDefaultMatchesSaveForFutureUse = FORM_ELEMENT_SET_DEFAULT_MATCHES_SAVE_FOR_FUTURE_DEFAULT_VALUE,
+                eventReporter = eventReporter,
+                savedStateHandle = savedStateHandle,
             )
         }
     }
@@ -110,10 +122,17 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode?
+        get() = savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM]
+        set(value) {
+            savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM] = value
+        }
+
     init {
         coroutineScope.launch {
             paymentSelection.collect { selection ->
                 selectionUpdater(selection)
+                reportFieldCompleted(selection?.paymentMethodType)
             }
         }
     }
@@ -188,4 +207,19 @@ internal class DefaultFormHelper(
     private fun supportedPaymentMethodForCode(code: String): SupportedPaymentMethod {
         return requireNotNull(paymentMethodMetadata.supportedPaymentMethodForCode(code = code))
     }
+
+    private fun reportFieldCompleted(code: PaymentMethodCode?) {
+        if (code == null || formTypeForCode(code) != FormType.UserInteractionRequired) {
+            return
+        }
+        /*
+         * Prevents this event from being reported multiple times on field interactions
+         * on the same payment form. We should have one field interaction event for
+         * every form shown event triggered.
+         */
+        if (previouslyCompletedForm != code) {
+            eventReporter.onPaymentMethodFormCompleted(code)
+            previouslyCompletedForm = code
+        }
+    }
 }
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt
index 3c006f3065e..af154993862 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporter.kt
@@ -242,6 +242,14 @@ internal class DefaultEventReporter @Inject internal constructor(
         )
     }
 
+    override fun onPaymentMethodFormCompleted(code: String) {
+        fireAnalyticEvent(
+            AnalyticEvent.CompletedPaymentMethodForm(
+                paymentMethodType = code,
+            )
+        )
+    }
+
     override fun onCardNumberCompleted() {
         fireEvent(
             PaymentSheetEvent.CardNumberCompleted(
diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt
index fa0e4498132..986dce0075e 100644
--- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt
+++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/analytics/EventReporter.kt
@@ -208,6 +208,13 @@ internal interface EventReporter {
         error: Throwable,
     )
 
+    /**
+     * The customer has completed all required payment form fields
+     */
+    fun onPaymentMethodFormCompleted(
+        code: PaymentMethodCode,
+    )
+
     /**
      * The customer cannot properly return from Link payments or other LPM payments using
      * browser intents.
diff --git a/paymentsheet/src/test/java/com/stripe/android/link/FakeNativeLinkComponent.kt b/paymentsheet/src/test/java/com/stripe/android/link/FakeNativeLinkComponent.kt
index 4dc9bb6c9fe..d195066fb82 100644
--- a/paymentsheet/src/test/java/com/stripe/android/link/FakeNativeLinkComponent.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/link/FakeNativeLinkComponent.kt
@@ -13,6 +13,8 @@ import com.stripe.android.link.confirmation.FakeLinkConfirmationHandler
 import com.stripe.android.link.confirmation.LinkConfirmationHandler
 import com.stripe.android.link.injection.NativeLinkComponent
 import com.stripe.android.payments.core.analytics.ErrorReporter
+import com.stripe.android.paymentsheet.analytics.EventReporter
+import com.stripe.android.paymentsheet.analytics.FakeEventReporter
 import com.stripe.android.testing.FakeErrorReporter
 import com.stripe.android.testing.FakeLogger
 import com.stripe.android.utils.NullCardAccountRangeRepositoryFactory
@@ -32,5 +34,6 @@ internal class FakeNativeLinkComponent(
     override val viewModel: LinkActivityViewModel = mock(),
     override val errorReporter: ErrorReporter = FakeErrorReporter(),
     override val linkAuth: LinkAuth = FakeLinkAuth(),
-    override val savedStateHandle: SavedStateHandle = SavedStateHandle()
+    override val savedStateHandle: SavedStateHandle = SavedStateHandle(),
+    override val eventReporter: EventReporter = FakeEventReporter(),
 ) : NativeLinkComponent
diff --git a/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodFormHelper.kt b/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodFormHelper.kt
index 6ef210de6d9..7838e2bae7a 100644
--- a/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodFormHelper.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodFormHelper.kt
@@ -1,5 +1,7 @@
 package com.stripe.android.link.ui.paymentmethod
 
+import app.cash.turbine.ReceiveTurbine
+import app.cash.turbine.Turbine
 import com.stripe.android.link.TestFactory
 import com.stripe.android.model.PaymentMethod
 import com.stripe.android.model.PaymentMethodCode
@@ -14,8 +16,11 @@ internal class PaymentMethodFormHelper : FormHelper {
     var paymentMethodCreateParams: PaymentMethodCreateParams? = PaymentMethodCreateParamsFixtures.DEFAULT_CARD
     val getPaymentMethodParamsCalls = arrayListOf<GetPaymentMethodParamsCall>()
 
+    private val _formFieldValuesChangedCall = Turbine<String>()
+    val formFieldValuesChangedCall: ReceiveTurbine<String> = _formFieldValuesChangedCall
+
     override fun onFormFieldValuesChanged(formValues: FormFieldValues?, selectedPaymentMethodCode: String) {
-        TODO("Not yet implemented")
+        _formFieldValuesChangedCall.add(selectedPaymentMethodCode)
     }
 
     override fun getPaymentMethodParams(
diff --git a/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt
index cd6aee62f06..1c9de3f22c1 100644
--- a/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
 import androidx.compose.ui.test.performTextReplacement
 import androidx.compose.ui.test.printToString
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
 import com.stripe.android.core.strings.resolvableString
 import com.stripe.android.link.TestFactory
 import com.stripe.android.link.account.FakeLinkAccountManager
@@ -31,6 +32,7 @@ import com.stripe.android.testing.FakeLogger
 import com.stripe.android.ui.core.elements.events.LocalCardBrandDisallowedReporter
 import com.stripe.android.ui.core.elements.events.LocalCardNumberCompletedEventReporter
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -47,17 +49,7 @@ internal class PaymentMethodScreenTest {
     val coroutineTestRule = CoroutineTestRule(dispatcher)
 
     @Test
-    fun `form fields are displayed correctly`() {
-        val linkAccountManager = FakeLinkAccountManager()
-
-        screen(
-            viewModel = createViewModel(
-                linkAccountManager = linkAccountManager
-            )
-        )
-
-        composeTestRule.waitForIdle()
-
+    fun `form fields are displayed correctly`() = runScenario {
         onCardNumber().assertExists()
         onCvc().assertIsDisplayed()
         onExpiryDate().assertIsDisplayed()
@@ -69,18 +61,9 @@ internal class PaymentMethodScreenTest {
     }
 
     @Test
-    fun `no error message on successful payment`() {
-        val linkAccountManager = FakeLinkAccountManager()
-
-        screen(
-            viewModel = createViewModel(
-                linkAccountManager = linkAccountManager
-            )
-        )
-
-        composeTestRule.waitForIdle()
-
+    fun `no error message on successful payment`() = runScenario {
         fillCardDetails()
+        assertThat(formHelper.formFieldValuesChangedCall.awaitItem()).isNotNull()
 
         onPayButton()
             .scrollToAndAssertDisplayed()
@@ -91,17 +74,7 @@ internal class PaymentMethodScreenTest {
     }
 
     @Test
-    fun `error message should be displayed on card creation failure`() {
-        val linkAccountManager = FakeLinkAccountManager()
-
-        screen(
-            viewModel = createViewModel(
-                linkAccountManager = linkAccountManager
-            )
-        )
-
-        composeTestRule.waitForIdle()
-
+    fun `error message should be displayed on card creation failure`() = runScenario {
         fillCardDetails()
 
         linkAccountManager.createCardPaymentDetailsResult = Result.failure(Throwable("oops"))
@@ -122,17 +95,7 @@ internal class PaymentMethodScreenTest {
     }
 
     @Test
-    fun `error message should be displayed on payment confirmation failure`() {
-        val linkConfirmationHandler = FakeLinkConfirmationHandler()
-
-        screen(
-            viewModel = createViewModel(
-                linkConfirmationHandler = linkConfirmationHandler
-            )
-        )
-
-        composeTestRule.waitForIdle()
-
+    fun `error message should be displayed on payment confirmation failure`() = runScenario {
         fillCardDetails()
 
         linkConfirmationHandler.confirmWithLinkPaymentDetailsResult =
@@ -170,10 +133,37 @@ internal class PaymentMethodScreenTest {
         }
     }
 
-    private fun createViewModel(
+    private fun runScenario(
         linkAccountManager: FakeLinkAccountManager = FakeLinkAccountManager(),
         linkConfirmationHandler: FakeLinkConfirmationHandler = FakeLinkConfirmationHandler(),
-        formHelper: FormHelper = PaymentMethodFormHelper()
+        formHelper: PaymentMethodFormHelper = PaymentMethodFormHelper(),
+        block: suspend Scenario.() -> Unit
+    ) {
+        screen(
+            viewModel = createViewModel(
+                linkAccountManager = linkAccountManager,
+                linkConfirmationHandler = linkConfirmationHandler,
+                formHelper = formHelper,
+            )
+        )
+        composeTestRule.waitForIdle()
+        Scenario(
+            linkAccountManager = linkAccountManager,
+            linkConfirmationHandler = linkConfirmationHandler,
+            formHelper = formHelper,
+        ).apply {
+            runTest {
+                formHelper.formFieldValuesChangedCall.skipItems(1)
+                block()
+            }
+        }
+        formHelper.formFieldValuesChangedCall.ensureAllEventsConsumed()
+    }
+
+    private fun createViewModel(
+        linkAccountManager: FakeLinkAccountManager,
+        linkConfirmationHandler: FakeLinkConfirmationHandler,
+        formHelper: FormHelper,
     ): PaymentMethodViewModel {
         return PaymentMethodViewModel(
             configuration = TestFactory.LINK_CONFIGURATION,
@@ -206,4 +196,10 @@ internal class PaymentMethodScreenTest {
             .performScrollTo()
             .assertIsDisplayed()
     }
+
+    private data class Scenario(
+        val linkAccountManager: FakeLinkAccountManager,
+        val linkConfirmationHandler: FakeLinkConfirmationHandler,
+        val formHelper: PaymentMethodFormHelper,
+    )
 }
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedContentHelperTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedContentHelperTest.kt
index 5c2aeffd210..564b65524b4 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedContentHelperTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedContentHelperTest.kt
@@ -107,7 +107,8 @@ internal class DefaultEmbeddedContentHelperTest {
         val embeddedFormHelperFactory = EmbeddedFormHelperFactory(
             linkConfigurationCoordinator = FakeLinkConfigurationCoordinator(),
             cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory,
-            embeddedSelectionHolder = selectionHolder
+            embeddedSelectionHolder = selectionHolder,
+            savedStateHandle = savedStateHandle,
         )
         val confirmationHandler = FakeConfirmationHandler()
         val eventReporter = FakeEventReporter()
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSelectionChooserTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSelectionChooserTest.kt
index 5f6ed4164bb..8be47c6a345 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSelectionChooserTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSelectionChooserTest.kt
@@ -15,6 +15,7 @@ import com.stripe.android.paymentelement.embedded.EmbeddedFormHelperFactory
 import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder
 import com.stripe.android.paymentelement.embedded.content.DefaultEmbeddedSelectionChooser.Companion.PREVIOUS_CONFIGURATION_KEY
 import com.stripe.android.paymentsheet.PaymentSheet
+import com.stripe.android.paymentsheet.analytics.FakeEventReporter
 import com.stripe.android.paymentsheet.model.PaymentSelection
 import com.stripe.android.testing.PaymentMethodFactory
 import com.stripe.android.utils.FakeLinkConfigurationCoordinator
@@ -324,12 +325,14 @@ internal class DefaultEmbeddedSelectionChooserTest {
             linkConfigurationCoordinator = FakeLinkConfigurationCoordinator(),
             embeddedSelectionHolder = EmbeddedSelectionHolder(MainThreadSavedStateHandle(savedStateHandle)),
             cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory,
+            savedStateHandle = savedStateHandle,
         )
         Scenario(
             chooser = DefaultEmbeddedSelectionChooser(
                 savedStateHandle = MainThreadSavedStateHandle(savedStateHandle),
                 formHelperFactory = formHelperFactory,
                 coroutineScope = CoroutineScope(Dispatchers.Unconfined),
+                eventReporter = FakeEventReporter(),
             ),
             savedStateHandle = savedStateHandle,
         ).block()
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityScreenShotTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityScreenShotTest.kt
index 56ffe1c1e39..8e913d7daba 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityScreenShotTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityScreenShotTest.kt
@@ -112,7 +112,8 @@ internal class FormActivityScreenShotTest {
         val formHelperFactory = EmbeddedFormHelperFactory(
             linkConfigurationCoordinator = FakeLinkConfigurationCoordinator(),
             embeddedSelectionHolder = selectionHolder,
-            cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory
+            cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory,
+            savedStateHandle = SavedStateHandle(),
         )
         val eventReporter = FakeEventReporter()
         val interactor = EmbeddedFormInteractorFactory(
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
index 10a6a6bdd31..14765135937 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
@@ -1,5 +1,6 @@
 package com.stripe.android.paymentsheet
 
+import androidx.lifecycle.SavedStateHandle
 import app.cash.turbine.TurbineTestContext
 import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
@@ -22,6 +23,7 @@ import com.stripe.android.model.PaymentMethodCreateParams.Companion.getNameFromP
 import com.stripe.android.model.PaymentMethodExtraParams
 import com.stripe.android.model.PaymentMethodOptionsParams
 import com.stripe.android.model.SetupIntentFixtures
+import com.stripe.android.paymentsheet.analytics.FakeEventReporter
 import com.stripe.android.paymentsheet.forms.FormFieldValues
 import com.stripe.android.paymentsheet.model.PaymentSelection
 import com.stripe.android.paymentsheet.ui.transformToPaymentMethodCreateParams
@@ -167,6 +169,7 @@ internal class FormHelperTest {
         )
         var hasCalledSelectionUpdater = false
         createFormHelper(
+            newPaymentSelectionProvider = { null },
             selectionUpdater = { paymentSelection ->
                 val cardPaymentSelection = paymentSelection as PaymentSelection.New.Card
                 assertThat(cardPaymentSelection.brand.code).isEqualTo(cardBrand)
@@ -178,6 +181,103 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runScenario {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            eventReporter = eventReporter,
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+        formHelper.onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+    }
+
+    @Test
+    fun `onPaymentMethodFormCompleted event should not be emitted when form is filled twice`() = runScenario {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            eventReporter = eventReporter,
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+
+        val firstFormFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        formHelper.onFormFieldValuesChanged(firstFormFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+
+        val secondFormFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("UK", true),
+                IdentifierSpec.Email to FormFieldEntry("Joey@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        formHelper.onFormFieldValuesChanged(secondFormFieldValues, "klarna")
+    }
+
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when different forms are filled`() = runScenario {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            eventReporter = eventReporter,
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+
+        val klarnaFormFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        formHelper.onFormFieldValuesChanged(klarnaFormFieldValues, "klarna")
+        val klarnaEvent = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(klarnaEvent.code).isEqualTo("klarna")
+
+        val cardFormFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.CardBrand to FormFieldEntry("visa", true),
+                IdentifierSpec.Name to FormFieldEntry("joe", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        formHelper.onFormFieldValuesChanged(cardFormFieldValues, "card")
+        val cardEvent = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(cardEvent.code).isEqualTo("card")
+    }
+
     @Test
     fun `onFormFieldValuesChanged & onLinkStateChanged calls create Link Inline selection when card`() = runTest {
         val formFieldValues = FormFieldValues(
@@ -352,7 +452,8 @@ internal class FormHelperTest {
                 linkInlineHandler = linkInlineHandler,
                 selectionUpdater = { paymentSelection ->
                     selection.value = paymentSelection
-                }
+                },
+                newPaymentSelectionProvider = { null }
             )
 
             formHelper.onFormFieldValuesChanged(formFieldValues, "card")
@@ -493,7 +594,8 @@ internal class FormHelperTest {
                 linkInlineHandler = linkInlineHandler,
                 selectionUpdater = { paymentSelection ->
                     selection.value = paymentSelection
-                }
+                },
+                newPaymentSelectionProvider = { null },
             )
 
             formHelper.onFormFieldValuesChanged(formFieldValues, paymentMethodCode)
@@ -508,6 +610,7 @@ internal class FormHelperTest {
     private fun createFormHelper(
         paymentMethodMetadata: PaymentMethodMetadata = PaymentMethodMetadataFactory.create(),
         linkInlineHandler: LinkInlineHandler = LinkInlineHandler.create(),
+        eventReporter: FakeEventReporter = FakeEventReporter(),
         newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { throw AssertionError("Not implemented") },
         selectionUpdater: (PaymentSelection?) -> Unit = { throw AssertionError("Not implemented") },
     ): FormHelper {
@@ -520,6 +623,26 @@ internal class FormHelperTest {
             linkInlineHandler = linkInlineHandler,
             selectionUpdater = selectionUpdater,
             setAsDefaultMatchesSaveForFutureUse = false,
+            eventReporter = eventReporter,
+            savedStateHandle = SavedStateHandle()
         )
     }
+
+    private fun runScenario(
+        eventReporter: FakeEventReporter = FakeEventReporter(),
+        block: suspend Scenario.() -> Unit,
+    ) {
+        Scenario(
+            eventReporter = eventReporter,
+        ).apply {
+            runTest {
+                block()
+            }
+        }
+        eventReporter.validate()
+    }
+
+    private data class Scenario(
+        val eventReporter: FakeEventReporter,
+    )
 }
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt
index 3ed8cae08ba..0a291f2ae75 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt
@@ -2293,7 +2293,10 @@ internal class PaymentSheetViewModelTest {
         val usBankAccount = PaymentSelection.New.USBankAccount(
             label = "Test",
             iconResource = 0,
-            paymentMethodCreateParams = mock(),
+            paymentMethodCreateParams = PaymentMethodCreateParams(
+                code = PaymentMethod.Type.USBankAccount.code,
+                requiresMandate = false,
+            ),
             customerRequestedSave = mock(),
             input = PaymentSelection.New.USBankAccount.Input(
                 name = "",
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt
index 07c34d44877..5d8dac3e854 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/DefaultEventReporterTest.kt
@@ -445,6 +445,24 @@ class DefaultEventReporterTest {
         )
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted() should fire analytics request with expected event value`() =
+        runTest(testDispatcher) {
+            val customEventReporter = createEventReporter(EventReporter.Mode.Custom) {
+                simulateSuccessfulSetup()
+            }
+
+            customEventReporter.onPaymentMethodFormCompleted(
+                code = "card",
+            )
+
+            analyticEventCallbackRule.assertMatchesExpectedEvent(
+                AnalyticEvent.CompletedPaymentMethodForm(
+                    paymentMethodType = "card"
+                )
+            )
+        }
+
     @Test
     fun `onCardNumberCompleted() should fire analytics request with expected event value`() {
         val customEventReporter = createEventReporter(EventReporter.Mode.Custom) {
diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/FakeEventReporter.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/FakeEventReporter.kt
index 51c4536130f..88b5267db9a 100644
--- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/FakeEventReporter.kt
+++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/analytics/FakeEventReporter.kt
@@ -58,6 +58,9 @@ internal class FakeEventReporter : EventReporter {
     private val _removePaymentMethodCalls = Turbine<RemovePaymentMethodCall>()
     val removePaymentMethodCalls: ReceiveTurbine<RemovePaymentMethodCall> = _removePaymentMethodCalls
 
+    private val _formCompletedCals = Turbine<FormCompletedCall>()
+    val formCompletedCalls: ReceiveTurbine<FormCompletedCall> = _formCompletedCals
+
     fun validate() {
         _paymentFailureCalls.ensureAllEventsConsumed()
         _paymentSuccessCalls.ensureAllEventsConsumed()
@@ -72,6 +75,7 @@ internal class FakeEventReporter : EventReporter {
         _showManageSavedPaymentMethods.ensureAllEventsConsumed()
         _experimentExposureCalls.ensureAllEventsConsumed()
         _removePaymentMethodCalls.ensureAllEventsConsumed()
+        _formCompletedCals.ensureAllEventsConsumed()
     }
 
     override fun onInit(
@@ -133,6 +137,14 @@ internal class FakeEventReporter : EventReporter {
     override fun onPaymentMethodFormInteraction(code: PaymentMethodCode) {
     }
 
+    override fun onPaymentMethodFormCompleted(code: String) {
+        _formCompletedCals.add(
+            FormCompletedCall(
+                code = code
+            )
+        )
+    }
+
     override fun onCardNumberCompleted() {
     }
 
@@ -264,4 +276,8 @@ internal class FakeEventReporter : EventReporter {
     data class RemovePaymentMethodCall(
         val code: PaymentMethodCode
     )
+
+    data class FormCompletedCall(
+        val code: PaymentMethodCode
+    )
 }


Reviews:
user: login: cttsai-stripe
body: Do we want a test to verify the event is only triggered once even if the user modifies a filled form?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T06:47:03Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T13:16:26Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T13:40:34Z
user: login: cttsai-stripe
body: Updated with event emitted in `PaymentSheetAnalyticsListener` and `EmbeddedFormInteractorFactory`.  This is becuase `DefaultFormHelper` has multiple constructors/create functions, and I am not sure if passing an `EventReporter` would be a good idea.

When leveraging `PaymentSelection`, I noticed:
1. It's easy to add an unit test for `PaymentSheetAnalyticsListener`, but I didn't figure out how to test embedded.
2. form completed events for US bank account would only be triggered after the authentication is completed. It seems an intended behavior from `PaymentSelection`
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T22:48:38Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:28:20Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T12:46:17Z
user: login: cttsai-stripe
body: > Yeah, we probably need to build something separate for us bank it behaves completely differently than the rest of the LPMs.

How should we achieve that? I did not find a clear clue to start with.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:57:20Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:18:21Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T19:18:52Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:32:50Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:45:00Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T21:04:50Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T21:05:32Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T21:06:16Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T21:07:45Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T04:30:40Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T04:37:22Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T04:37:30Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T13:42:50Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T13:45:08Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T13:45:59Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T20:18:33Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T20:42:09Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T22:24:05Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T22:24:25Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T22:25:22Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T22:29:13Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T22:36:16Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T22:37:43Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T22:41:14Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:21:37Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:23:14Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T23:24:32Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:32:12Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:32:46Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:33:39Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:36:17Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T16:03:43Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:05:08Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:07:33Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:08:30Z
user: login: jaynewstrom-stripe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T18:58:47Z
user: login: cttsai-stripe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T19:37:45Z
user: login: jaynewstrom-stripe
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T19:40:17Z

Review comments:
diff_hunk: @@ -101,6 +101,8 @@ internal class EmbeddedPaymentElementAnalyticsTest {
 
         embeddedContentPage.clickOnLpm("card")
         formPage.fillOutCardDetails()
+        testRules.compose.waitForIdle()
path: paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
user: login: cttsai-stripe
body: This is needed to pass the test, but I am not sure why. Waiting for idle is not necessary in the `PaymentSheetAnalyticsTest`
created_at: 2025-04-08T06:41:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,11 +460,19 @@ private fun PrimaryButton(viewModel: BaseSheetViewModel) {
         modifier = modifier,
     )
 
-    LaunchedEffect(viewModel, button) {
+    LaunchedEffect(viewModel, button, currentScreen) {
         viewModel.primaryButtonUiState.collect { uiState ->
             withContext(Dispatchers.Main) {
                 button?.updateUiState(uiState)
             }
+            if (canEmitPMCompletedEvent && uiState?.enabled == true) {
+                viewModel.newPaymentSelection?.let { newPaymentSelection ->
+                    viewModel.eventReporter.onPaymentMethodFormCompleted(
+                        newPaymentSelection.getPaymentMethodCode()
+                    )
+                    canEmitPMCompletedEvent = false
+                }
+            }
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetScreen.kt
user: login: cttsai-stripe
body: `currentScreen` is required to be set as a key, ensuring the flow to be recollected with the updated `canEmitPMCompletedEvent`.

Ultimately we might want to totally get rid of this `LaunchedEffect` like proposed [here](https://github.com/stripe/stripe-android/pull/10524#discussion_r2025282468).
created_at: 2025-04-08T06:45:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,9 +48,19 @@ internal fun FormActivityUI(
 ) {
     val scrollState = rememberScrollState()
     val interactorState by interactor.state.collectAsState()
+    var canEmitPMCompletedEvent by remember { mutableStateOf(true) }
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivityUI.kt
user: login: jaynewstrom-stripe
body: Should this be `rememberSaveable`?
created_at: 2025-04-08T13:09:31Z
author_association: COLLABORATOR
diff_hunk: @@ -44,9 +48,19 @@ internal fun FormActivityUI(
 ) {
     val scrollState = rememberScrollState()
     val interactorState by interactor.state.collectAsState()
+    var canEmitPMCompletedEvent by remember { mutableStateOf(true) }
 
     DismissKeyboardOnProcessing(interactorState.isProcessing)
 
+    LaunchedEffect(state) {
+        if (state.isEnabled && canEmitPMCompletedEvent) {
+            eventReporter.onPaymentMethodFormCompleted(
+                interactorState.selectedPaymentMethodCode
+            )
+            canEmitPMCompletedEvent = false
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivityUI.kt
user: login: jaynewstrom-stripe
body: I find this name a little misleading. "can" tells me there's some behavior that would prevent this from being fired at all. Maybe "has" would communicate it more clearly. "has" means it hasn't happened _yet_ - rather than it won't happen at all.
created_at: 2025-04-08T13:10:28Z
author_association: COLLABORATOR
diff_hunk: @@ -460,11 +460,19 @@ private fun PrimaryButton(viewModel: BaseSheetViewModel) {
         modifier = modifier,
     )
 
-    LaunchedEffect(viewModel, button) {
+    LaunchedEffect(viewModel, button, currentScreen) {
         viewModel.primaryButtonUiState.collect { uiState ->
             withContext(Dispatchers.Main) {
                 button?.updateUiState(uiState)
             }
+            if (canEmitPMCompletedEvent && uiState?.enabled == true) {
+                viewModel.newPaymentSelection?.let { newPaymentSelection ->
+                    viewModel.eventReporter.onPaymentMethodFormCompleted(
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetScreen.kt
user: login: jaynewstrom-stripe
body: Thinking more about these, maybe we should follow the pattern set by `PaymentSheetAnalyticsListener` here. This "business logic" probably shouldn't live in the UI code.
created_at: 2025-04-08T13:13:07Z
author_association: COLLABORATOR
diff_hunk: @@ -460,11 +460,19 @@ private fun PrimaryButton(viewModel: BaseSheetViewModel) {
         modifier = modifier,
     )
 
-    LaunchedEffect(viewModel, button) {
+    LaunchedEffect(viewModel, button, currentScreen) {
         viewModel.primaryButtonUiState.collect { uiState ->
             withContext(Dispatchers.Main) {
                 button?.updateUiState(uiState)
             }
+            if (canEmitPMCompletedEvent && uiState?.enabled == true) {
+                viewModel.newPaymentSelection?.let { newPaymentSelection ->
+                    viewModel.eventReporter.onPaymentMethodFormCompleted(
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetScreen.kt
user: login: jaynewstrom-stripe
body: Actually, this seems like not the correct place at all to send this event. It seems like it should be coming from an Interactor, or somewhere deeper in the architecture.
created_at: 2025-04-08T13:16:03Z
author_association: COLLABORATOR
diff_hunk: @@ -460,11 +460,19 @@ private fun PrimaryButton(viewModel: BaseSheetViewModel) {
         modifier = modifier,
     )
 
-    LaunchedEffect(viewModel, button) {
+    LaunchedEffect(viewModel, button, currentScreen) {
         viewModel.primaryButtonUiState.collect { uiState ->
             withContext(Dispatchers.Main) {
                 button?.updateUiState(uiState)
             }
+            if (canEmitPMCompletedEvent && uiState?.enabled == true) {
+                viewModel.newPaymentSelection?.let { newPaymentSelection ->
+                    viewModel.eventReporter.onPaymentMethodFormCompleted(
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetScreen.kt
user: login: jaynewstrom-stripe
body: Maybe https://github.com/stripe/stripe-android/blob/428aab565363e8d40f2bb4c25cefaddf076dcde9/paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt#L115
created_at: 2025-04-08T13:40:34Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +181,29 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null }
+        ).onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+        eventReporter.validate()
+    }
+
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: How do I complete a form? I tried different settings but still doesn't work.
created_at: 2025-04-09T00:28:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +181,29 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null }
+        ).onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+        eventReporter.validate()
+    }
+
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: The test above this does it https://github.com/stripe/stripe-android/blob/d8e5ccc2fe2937d042e5a5e3f95d110db61186d8/paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt#L178


created_at: 2025-04-09T12:46:17Z
author_association: COLLABORATOR
diff_hunk: @@ -508,8 +535,8 @@ internal class FormHelperTest {
     private fun createFormHelper(
         paymentMethodMetadata: PaymentMethodMetadata = PaymentMethodMetadataFactory.create(),
         linkInlineHandler: LinkInlineHandler = LinkInlineHandler.create(),
-        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { throw AssertionError("Not implemented") },
-        selectionUpdater: (PaymentSelection?) -> Unit = { throw AssertionError("Not implemented") },
+        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { null },
+        selectionUpdater: (PaymentSelection?) -> Unit = { },
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: This is the root cause that fails the test. `newPaymentSelectionProvider` is used when checking `formTypeForCode`. With the default setting, the function will throw `AssertionError`
```
 private fun reportFieldCompleted(code: PaymentMethodCode?) {
        if (code == null || formTypeForCode(code) != FormType.UserInteractionRequired) {
            return
        }
```
However, the `AssertionError` does not actually fail the test, but only breaks the coroutine. This actually makes the failure harder to debug.
```
coroutineScope.launch {
            paymentSelection.collect { selection ->
                selectionUpdater(selection)
                reportFieldCompleted(selection?.paymentMethodType)
            }
        }
```
Is there a reason we would use an `AssertionError` here?
created_at: 2025-04-09T18:51:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +181,28 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+        ).onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+        eventReporter.validate()
+    }
+
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: Does `onFormFieldValuesChanged` only trigger when a form is fully completed, or can it fire during partial completion? After setting `newPaymentSelectionProvider` to default null, the test passes, but I'd like to understand the event's exact triggering conditions.
created_at: 2025-04-09T18:55:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +181,28 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+        ).onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+        eventReporter.validate()
+    }
+
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: I'd encourage you to attach a debugger and poke around running the app and clicking buttons.
created_at: 2025-04-09T19:18:21Z
author_association: COLLABORATOR
diff_hunk: @@ -508,8 +535,8 @@ internal class FormHelperTest {
     private fun createFormHelper(
         paymentMethodMetadata: PaymentMethodMetadata = PaymentMethodMetadataFactory.create(),
         linkInlineHandler: LinkInlineHandler = LinkInlineHandler.create(),
-        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { throw AssertionError("Not implemented") },
-        selectionUpdater: (PaymentSelection?) -> Unit = { throw AssertionError("Not implemented") },
+        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { null },
+        selectionUpdater: (PaymentSelection?) -> Unit = { },
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: We don't expect these to be called by default! If you expect them to be called, please pass in a value.
created_at: 2025-04-09T19:18:52Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +181,28 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+        ).onFormFieldValuesChanged(formFieldValues, "klarna")
+        val event = eventReporter.formCompletedCalls.awaitItem()
+        assertThat(event.code).isEqualTo("klarna")
+        eventReporter.validate()
+    }
+
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: **BLUF: yes but no**
`onFormFieldValuesChanged` should only be triggered when the fields are completed, but we check the condition in `PaymentMethodForm`, which is a UI component that clearly would not be called in this unit test.
```
LaunchedEffect(paymentMethodCode) {
        completeFormValues.distinctUntilChanged().collect {
            onFormFieldValuesChanged(it)
        }
    }
```
It might be ok to _pretend_ all the fields are filled in this test unless we have a better approach to validate if a form is completely filled.
created_at: 2025-04-09T20:32:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -508,8 +535,8 @@ internal class FormHelperTest {
     private fun createFormHelper(
         paymentMethodMetadata: PaymentMethodMetadata = PaymentMethodMetadataFactory.create(),
         linkInlineHandler: LinkInlineHandler = LinkInlineHandler.create(),
-        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { throw AssertionError("Not implemented") },
-        selectionUpdater: (PaymentSelection?) -> Unit = { throw AssertionError("Not implemented") },
+        newPaymentSelectionProvider: () -> NewPaymentOptionSelection? = { null },
+        selectionUpdater: (PaymentSelection?) -> Unit = { },
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: updated
created_at: 2025-04-09T20:45:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +101,8 @@ internal class EmbeddedPaymentElementAnalyticsTest {
 
         embeddedContentPage.clickOnLpm("card")
         formPage.fillOutCardDetails()
+        testRules.compose.waitForIdle()
path: paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
user: login: jaynewstrom-stripe
body: That's surprising, given we wait in the assertMatchesExpectedEvent. Can you dig into this, I don't think we should need it.
created_at: 2025-04-09T21:04:50Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +95,11 @@ internal class DefaultEmbeddedSelectionChooser @Inject constructor(
         previousSelection: PaymentSelection.New,
         paymentMethodMetadata: PaymentMethodMetadata,
     ): Boolean {
-        val newFormType = formHelperFactory.create(coroutineScope, paymentMethodMetadata) {}
+        val newFormType = formHelperFactory.create(
+            coroutineScope,
+            paymentMethodMetadata,
+            eventReporter = null
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
user: login: jaynewstrom-stripe
body: I don't think we should pass null, and I also don't think it should be nullable.

Why is it nullable?
created_at: 2025-04-09T21:05:31Z
author_association: COLLABORATOR
diff_hunk: @@ -110,10 +115,27 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode? = null
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: jaynewstrom-stripe
body: Should this use savedStateHandle? If not, why not?
created_at: 2025-04-09T21:06:16Z
author_association: COLLABORATOR
diff_hunk: @@ -43,6 +44,8 @@ import kotlin.test.Test
 @RunWith(RobolectricTestRunner::class)
 internal class FormHelperTest {
 
+    val eventReporter = FakeEventReporter()
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: This should be baked into the createFormHelper, or a scenario, or similar.
created_at: 2025-04-09T21:07:45Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +95,11 @@ internal class DefaultEmbeddedSelectionChooser @Inject constructor(
         previousSelection: PaymentSelection.New,
         paymentMethodMetadata: PaymentMethodMetadata,
     ): Boolean {
-        val newFormType = formHelperFactory.create(coroutineScope, paymentMethodMetadata) {}
+        val newFormType = formHelperFactory.create(
+            coroutineScope,
+            paymentMethodMetadata,
+            eventReporter = null
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
user: login: cttsai-stripe
body: Updated with non null event reporter.
created_at: 2025-04-10T04:30:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,10 +115,27 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode? = null
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: cttsai-stripe
body: Probably. This works fine because `DefaultFormHelper` has a long lifecycle. It is tied to `PaymentMethodVerticalLayoutInteractor` and `AddPaymentMethodInteractor`. The interactors are parts of their corresponding view models. So basically `DefaultFormHelper` would somewhat has the same lifecycle as view models that allows it to survive the configuration change.

That being said, relying on this implicit reference chain is not ideal. I am wondering if we should deal with `savedStateHandle` in a follow up PR though. Is it easy to make the change?
created_at: 2025-04-10T04:37:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,6 +44,8 @@ import kotlin.test.Test
 @RunWith(RobolectricTestRunner::class)
 internal class FormHelperTest {
 
+    val eventReporter = FakeEventReporter()
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: updated
created_at: 2025-04-10T04:37:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,10 +115,27 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode? = null
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: jaynewstrom-stripe
body: It should be trivial to make the change! `PaymentSheetAnalyticsListener` has a lot of very similar examples. I don't think it makes sense to do as a follow up.
created_at: 2025-04-10T13:42:50Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +180,32 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+        formHelper.onFormFieldValuesChanged(formFieldValues, "klarna")
+        val eventReporter = ((formHelper as DefaultFormHelper).eventReporter as FakeEventReporter)
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: We shouldn't do this! We shouldn't have a way to pass it into `createFormHelper`.
created_at: 2025-04-10T13:45:08Z
author_association: COLLABORATOR
diff_hunk: @@ -101,6 +101,8 @@ internal class EmbeddedPaymentElementAnalyticsTest {
 
         embeddedContentPage.clickOnLpm("card")
         formPage.fillOutCardDetails()
+        testRules.compose.waitForIdle()
path: paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
user: login: jaynewstrom-stripe
body: Friendly ping here.
created_at: 2025-04-10T13:45:59Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +180,32 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+        formHelper.onFormFieldValuesChanged(formFieldValues, "klarna")
+        val eventReporter = ((formHelper as DefaultFormHelper).eventReporter as FakeEventReporter)
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: Can you explain a bit more? I was not trying to pass a `eventReporter` into `createFormHelper`. In contrast, I was trying to fetch the `eventReporter` from the `formHelper`. Maybe doing this with scenario would be a better idea? 
created_at: 2025-04-10T20:18:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +180,32 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+        formHelper.onFormFieldValuesChanged(formFieldValues, "klarna")
+        val eventReporter = ((formHelper as DefaultFormHelper).eventReporter as FakeEventReporter)
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: Other tests use a `Scenario` to both create an instance, pass it into the subject under test (`DefaultFormHelper` in this case). And expose the `FakeEventReporter` publicly via the `Scenario` rather than pulling out an implementation detail.

`eventReporter` should be a private instance variable that's not exposed outside of the `DefaultFormHelper` class.
created_at: 2025-04-10T20:42:09Z
author_association: COLLABORATOR
diff_hunk: @@ -110,10 +115,27 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode? = null
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: cttsai-stripe
body: I passed `savedStateHandle` into `DefaultFormHelper` since `PaymentSheetAnalyticsListener` does not have access to the necessary helper function `formTypeForCode`
created_at: 2025-04-10T22:24:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +180,32 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runTest {
+        val customerRequestedSave = PaymentSelection.CustomerRequestedSave.RequestNoReuse
+        val formFieldValues = FormFieldValues(
+            fieldValuePairs = mapOf(
+                IdentifierSpec.Country to FormFieldEntry("US", true),
+                IdentifierSpec.Email to FormFieldEntry("Joe@stripe.com", true),
+            ),
+            userRequestedReuse = customerRequestedSave,
+        )
+        val formHelper = createFormHelper(
+            paymentMethodMetadata = PaymentMethodMetadataFactory.create(
+                stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
+                    paymentMethodTypes = listOf("card", "klarna"),
+                )
+            ),
+            newPaymentSelectionProvider = { null },
+            selectionUpdater = {},
+        )
+        formHelper.onFormFieldValuesChanged(formFieldValues, "klarna")
+        val eventReporter = ((formHelper as DefaultFormHelper).eventReporter as FakeEventReporter)
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: updated
created_at: 2025-04-10T22:24:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,6 +101,8 @@ internal class EmbeddedPaymentElementAnalyticsTest {
 
         embeddedContentPage.clickOnLpm("card")
         formPage.fillOutCardDetails()
+        testRules.compose.waitForIdle()
path: paymentsheet/src/androidTest/java/com/stripe/android/paymentelement/EmbeddedPaymentElementAnalyticsTest.kt
user: login: cttsai-stripe
body: I tried again without `waitForIdle` and it passed. I ran 50 times with the shampoo rule to make sure it is not flaky.
created_at: 2025-04-10T22:25:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,7 +50,7 @@ internal class LinkActivityViewModel @Inject constructor(
     val eventReporter: EventReporter,
     private val linkConfiguration: LinkConfiguration,
     private val linkAttestationCheck: LinkAttestationCheck,
-    private val savedStateHandle: SavedStateHandle,
+    val savedStateHandle: SavedStateHandle,
path: paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt
user: login: cttsai-stripe
body: Needed to pass `savedStateBundle` in `PaymentMethodViewModel`
created_at: 2025-04-10T22:27:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,11 +18,13 @@ internal class EmbeddedFormHelperFactory @Inject constructor(
     private val linkConfigurationCoordinator: LinkConfigurationCoordinator,
     private val embeddedSelectionHolder: EmbeddedSelectionHolder,
     private val cardAccountRangeRepositoryFactory: CardAccountRangeRepository.Factory,
+    private val savedStateHandle: SavedStateHandle,
 ) {
     fun create(
         coroutineScope: CoroutineScope,
         paymentMethodMetadata: PaymentMethodMetadata,
-        selectionUpdater: (PaymentSelection?) -> Unit
+        eventReporter: EventReporter,
+        selectionUpdater: (PaymentSelection?) -> Unit,
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
user: login: jaynewstrom-stripe
body: this looks unused, or am I missing something
created_at: 2025-04-10T22:36:16Z
author_association: COLLABORATOR
diff_hunk: @@ -110,10 +122,31 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode?
+        get() = savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM]
+        set(value) {
+            savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM] = value
+        }
+    private fun reportFieldCompleted(code: PaymentMethodCode?) {
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: jaynewstrom-stripe
body: Normally we organize with functions below the initializer. Mind cleaning this up?
created_at: 2025-04-10T22:37:43Z
author_association: COLLABORATOR
diff_hunk: @@ -170,10 +133,37 @@ internal class PaymentMethodScreenTest {
         }
     }
 
-    private fun createViewModel(
+    private fun runScenario(
path: paymentsheet/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt
user: login: jaynewstrom-stripe
body: For the future, ideally refactors like this are done as pre work in a separate PR.
created_at: 2025-04-10T22:41:14Z
author_association: COLLABORATOR
diff_hunk: @@ -16,11 +18,13 @@ internal class EmbeddedFormHelperFactory @Inject constructor(
     private val linkConfigurationCoordinator: LinkConfigurationCoordinator,
     private val embeddedSelectionHolder: EmbeddedSelectionHolder,
     private val cardAccountRangeRepositoryFactory: CardAccountRangeRepository.Factory,
+    private val savedStateHandle: SavedStateHandle,
 ) {
     fun create(
         coroutineScope: CoroutineScope,
         paymentMethodMetadata: PaymentMethodMetadata,
-        selectionUpdater: (PaymentSelection?) -> Unit
+        eventReporter: EventReporter,
+        selectionUpdater: (PaymentSelection?) -> Unit,
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
user: login: cttsai-stripe
body: In `DefaultFormHelper`,
`selectionUpdater` is used when collecting `paymentSelection`, and
`eventReporter` is used in `reportFieldCompleted`.

Is that what you mean?
created_at: 2025-04-10T23:21:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,10 +122,31 @@ internal class DefaultFormHelper(
         }
     }
 
+    private var previouslyCompletedForm: PaymentMethodCode?
+        get() = savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM]
+        set(value) {
+            savedStateHandle[PREVIOUSLY_COMPLETED_PAYMENT_FORM] = value
+        }
+    private fun reportFieldCompleted(code: PaymentMethodCode?) {
path: paymentsheet/src/main/java/com/stripe/android/paymentsheet/DefaultFormHelper.kt
user: login: cttsai-stripe
body: done
created_at: 2025-04-10T23:23:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,11 +18,13 @@ internal class EmbeddedFormHelperFactory @Inject constructor(
     private val linkConfigurationCoordinator: LinkConfigurationCoordinator,
     private val embeddedSelectionHolder: EmbeddedSelectionHolder,
     private val cardAccountRangeRepositoryFactory: CardAccountRangeRepository.Factory,
+    private val savedStateHandle: SavedStateHandle,
 ) {
     fun create(
         coroutineScope: CoroutineScope,
         paymentMethodMetadata: PaymentMethodMetadata,
-        selectionUpdater: (PaymentSelection?) -> Unit
+        eventReporter: EventReporter,
+        selectionUpdater: (PaymentSelection?) -> Unit,
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedFormHelperFactory.kt
user: login: jaynewstrom-stripe
body: Ohh, my bad. I read this diff wrong.
created_at: 2025-04-10T23:24:31Z
author_association: COLLABORATOR
diff_hunk: @@ -50,7 +50,7 @@ internal class LinkActivityViewModel @Inject constructor(
     val eventReporter: EventReporter,
     private val linkConfiguration: LinkConfiguration,
     private val linkAttestationCheck: LinkAttestationCheck,
-    private val savedStateHandle: SavedStateHandle,
+    val savedStateHandle: SavedStateHandle,
path: paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt
user: login: jaynewstrom-stripe
body: This is pretty hacky... I see we're already doing it with eventReporter, but we shouldn't do these sorts of things. Using dependency injection via dagger is supposed to make these sorts of things uncessary.

No action required, but let's avoid things like this if there's a way (without a significant amount of work).
created_at: 2025-04-11T13:32:12Z
author_association: COLLABORATOR
diff_hunk: @@ -54,6 +55,7 @@ internal interface NativeLinkComponent {
     val linkAuth: LinkAuth
     val savedStateHandle: SavedStateHandle
     val viewModel: LinkActivityViewModel
+    val eventReporter: EventReporter
path: paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
user: login: jaynewstrom-stripe
body: Also not really what we should be doing, we should just expose the thing we actually need to use.
created_at: 2025-04-11T13:32:46Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +97,11 @@ internal class DefaultEmbeddedSelectionChooser @Inject constructor(
         previousSelection: PaymentSelection.New,
         paymentMethodMetadata: PaymentMethodMetadata,
     ): Boolean {
-        val newFormType = formHelperFactory.create(coroutineScope, paymentMethodMetadata) {}
+        val newFormType = formHelperFactory.create(
+            coroutineScope,
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
user: login: jaynewstrom-stripe
body: Typically we add names to the call arguments, especially when it spans multiple lines.
created_at: 2025-04-11T13:33:39Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +181,31 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runScenario {
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: We're missing at least 2 tests.
* When filling out the form a second time, an event is not emitted
* When filling out a different form, a new event is emitted
created_at: 2025-04-11T13:36:17Z
author_association: COLLABORATOR
diff_hunk: @@ -54,6 +55,7 @@ internal interface NativeLinkComponent {
     val linkAuth: LinkAuth
     val savedStateHandle: SavedStateHandle
     val viewModel: LinkActivityViewModel
+    val eventReporter: EventReporter
path: paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
user: login: cttsai-stripe
body: I tried not exposing the any unnecessary variables. In this case, the dependency is `DefaultFormHelper` ->  `PaymentMethodViewModel` -> `NativeLinkComponent`. Is there a better way to fetch the event reporter? 
(I think this is why I initially passed a null event reporter here)
created_at: 2025-04-11T16:03:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,6 +55,7 @@ internal interface NativeLinkComponent {
     val linkAuth: LinkAuth
     val savedStateHandle: SavedStateHandle
     val viewModel: LinkActivityViewModel
+    val eventReporter: EventReporter
path: paymentsheet/src/main/java/com/stripe/android/link/injection/NativeLinkComponent.kt
user: login: jaynewstrom-stripe
body: This is another no action required. I'd have to look deeper to see what's possible. But we're already not doing things ideally, so no need to change course now.
created_at: 2025-04-11T16:05:07Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +97,11 @@ internal class DefaultEmbeddedSelectionChooser @Inject constructor(
         previousSelection: PaymentSelection.New,
         paymentMethodMetadata: PaymentMethodMetadata,
     ): Boolean {
-        val newFormType = formHelperFactory.create(coroutineScope, paymentMethodMetadata) {}
+        val newFormType = formHelperFactory.create(
+            coroutineScope,
path: paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedSelectionChooser.kt
user: login: cttsai-stripe
body: done
created_at: 2025-04-11T18:07:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +181,31 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runScenario {
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: Looks reasonable to test them at once in an integration test. I added `testFillOutForm` in `EmbeddedPaymentElementAnalyticsTest`
created_at: 2025-04-11T18:08:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,6 +181,31 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runScenario {
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: jaynewstrom-stripe
body: This feels like a reasonable ask for unit tests. Could you please add them as unit tests.
created_at: 2025-04-11T18:58:47Z
author_association: COLLABORATOR
diff_hunk: @@ -178,6 +181,31 @@ internal class FormHelperTest {
         assertThat(hasCalledSelectionUpdater).isTrue()
     }
 
+    @Test
+    fun `onPaymentMethodFormCompleted event emitted when form is filled`() = runScenario {
path: paymentsheet/src/test/java/com/stripe/android/paymentsheet/FormHelperTest.kt
user: login: cttsai-stripe
body: done
created_at: 2025-04-11T19:37:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-08T00:54:37Z
body: Diffuse output:


```
OLD: identity-example-release-base.apk (signature: V1, V2)
NEW: identity-example-release-pr.apk (signature: V1, V2)

          │          compressed          │         uncompressed         
          ├───────────┬───────────┬──────┼───────────┬───────────┬──────
 APK      │ old       │ new       │ diff │ old       │ new       │ diff 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
      dex │     2 MiB │     2 MiB │  0 B │   4.1 MiB │   4.1 MiB │  0 B 
     arsc │     1 MiB │     1 MiB │  0 B │     1 MiB │     1 MiB │  0 B 
 manifest │   2.3 KiB │   2.3 KiB │  0 B │     8 KiB │     8 KiB │  0 B 
      res │ 302.9 KiB │ 302.9 KiB │  0 B │   457 KiB │   457 KiB │  0 B 
   native │   6.2 MiB │   6.2 MiB │  0 B │  15.8 MiB │  15.8 MiB │  0 B 
    asset │   7.3 KiB │   7.3 KiB │  0 B │   7.1 KiB │   7.1 KiB │  0 B 
    other │  95.2 KiB │  95.2 KiB │ +3 B │ 182.4 KiB │ 182.4 KiB │  0 B 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
    total │   9.6 MiB │   9.6 MiB │ +3 B │  21.6 MiB │  21.6 MiB │  0 B 

 DEX     │ old   │ new   │ diff      
─────────┼───────┼───────┼───────────
   files │     1 │     1 │ 0         
 strings │ 20142 │ 20142 │ 0 (+0 -0) 
   types │  6235 │  6235 │ 0 (+0 -0) 
 classes │  5026 │  5026 │ 0 (+0 -0) 
 methods │ 30047 │ 30047 │ 0 (+0 -0) 
  fields │ 17384 │ 17384 │ 0 (+0 -0) 

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  164 │  164 │  0   
 entries │ 3644 │ 3644 │  0
```

<details>
  <summary>APK</summary>
  
```
   compressed    │   uncompressed   │                                           
──────────┬──────┼───────────┬──────┤                                           
 size     │ diff │ size      │ diff │ path                                      
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
 25.7 KiB │ +3 B │    64 KiB │  0 B │ ∆ META-INF/MANIFEST.MF                    
    272 B │ +1 B │     120 B │  0 B │ ∆ META-INF/version-control-info.textproto 
   29 KiB │ -1 B │    64 KiB │  0 B │ ∆ META-INF/CERT.SF                        
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
 54.9 KiB │ +3 B │ 128.1 KiB │  0 B │ (total)
```
</details>
author_association: CONTRIBUTOR
user: login: jaynewstrom-stripe
created_at: 2025-04-08T13:08:00Z
body: > Do we want a test to verify the event is only triggered once even if the user modifies a filled form?

Probably
author_association: COLLABORATOR
user: login: jaynewstrom-stripe
created_at: 2025-04-08T23:04:08Z
body: >I am not sure if passing an EventReporter would be a good idea.

Why not? It still seems like a good idea to me.
author_association: COLLABORATOR
user: login: cttsai-stripe
created_at: 2025-04-08T23:32:35Z
body: > > I am not sure if passing an EventReporter would be a good idea.
> 
> Why not? It still seems like a good idea to me.

Does `FormHelper` work with US bank account?
I tried the following code but it only works for cards in PaymentSheet/Embedded
```
 private var previouslyCompletedForm: PaymentMethodCode? = null
    private fun reportFieldCompleted(code: PaymentMethodCode) {
        /*
         * Prevents this event from being reported multiple times on field interactions
         * on the same payment form. We should have one field interaction event for
         * every form shown event triggered.
         */
        if (previouslyCompletedForm != code) {
            eventReporter?.onPaymentMethodFormCompleted(code)
            previouslyCompletedForm = code
        }
    }

    init {
        coroutineScope.launch {
            paymentSelection.collect { selection ->
                selectionUpdater(selection)
                selection?.let {
                    reportFieldCompleted(it.paymentMethodType)
                }
            }
        }
    }
  ```
  
author_association: CONTRIBUTOR
user: login: jaynewstrom-stripe
created_at: 2025-04-08T23:38:03Z
body: Yeah, we probably need to build something separate for us bank it behaves completely differently than the rest of the LPMs. But the `FormHelper` solution should work for all other integration types, and all other payment methods, right?
author_association: COLLABORATOR
user: login: cttsai-stripe
created_at: 2025-04-08T23:41:47Z
body: Right. And I am wondering if we actually need to emit the events for those without forms, e.g. cash app, crypto. Is there a way we can filter them out?
author_association: CONTRIBUTOR
user: login: jaynewstrom-stripe
created_at: 2025-04-08T23:51:50Z
body: That's another reason why I think `FormHelper` is the right place! It has a private function `requiresFormScreen`. Or you can use `formTypeForCode`.
author_association: COLLABORATOR
user: login: jaynewstrom-stripe
created_at: 2025-04-09T19:19:08Z
body: Let's tackle US bank as a follow up.
author_association: COLLABORATOR