From 4a18fb4510a554192cf5fc5f8f3f59fa2918a9b2 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Thu, 28 Nov 2024 13:27:47 -0500 Subject: [PATCH] Allow `instant_or_skip` verification for ACH (#9695) * Allow `instant_or_skip` verification for ACH * Add tests * Add changelog note * Update tests * Avoid public API changes Introduce `FinancialConnectionsSheetInternalResult` and a `toPublicResult` method * Store `instantlyVerified` in `BankAccount` instead --- CHANGELOG.md | 2 + .../FinancialConnectionsSheetViewModel.kt | 12 +++-- .../financialconnections/model/BankAccount.kt | 6 ++- .../FinancialConnectionsSessionExtensions.kt | 35 +++++++++++++ ...inancialConnectionsSheetNativeViewModel.kt | 15 ++++-- .../FinancialConnectionsSheetViewModelTest.kt | 36 +++++++++++++ ...cialConnectionsSheetNativeViewModelTest.kt | 50 +++++++++++++++++++ .../AddPaymentMethodRequirement.kt | 13 +++-- .../ach/USBankAccountFormViewModel.kt | 2 +- .../ach/USBankAccountFormViewModelTest.kt | 34 +++++++++++-- 10 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionExtensions.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f0054af45..f2a760fce96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # CHANGELOG ## XX.XX.XX - 20XX-XX-XX +### PaymentSheet +- [CHANGED][9695](https://github.com/stripe/stripe-android/pull/9695) US Bank Account now supports the `instant_or_skip` verification method. ## 21.2.0 - 2024-11-19 ### Payments diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt index 4ec570fd775..1fde5080edd 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModel.kt @@ -52,6 +52,7 @@ import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.SynchronizeSessionResponse +import com.stripe.android.financialconnections.model.update import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity @@ -297,10 +298,11 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor( viewModelScope.launch { kotlin.runCatching { fetchFinancialConnectionsSession(state.sessionSecret) - }.onSuccess { + }.onSuccess { session -> + val updatedSession = session.update(state.manifest) finishWithResult( state = state, - result = Completed(financialConnectionsSession = it) + result = Completed(financialConnectionsSession = updatedSession) ) }.onFailure { error -> finishWithResult(stateFlow.value, Failed(error)) @@ -320,9 +322,13 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor( kotlin.runCatching { fetchFinancialConnectionsSessionForToken(clientSecret = state.sessionSecret) }.onSuccess { (las, token) -> + val updatedSession = las.update(state.manifest) finishWithResult( state = state, - result = Completed(financialConnectionsSession = las, token = token) + result = Completed( + financialConnectionsSession = updatedSession, + token = token, + ) ) }.onFailure { error -> finishWithResult(stateFlow.value, Failed(error)) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/BankAccount.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/BankAccount.kt index 4fa30db63e2..ca602652aa3 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/BankAccount.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/BankAccount.kt @@ -19,6 +19,10 @@ data class BankAccount( @SerialName(value = "bank_name") val bankName: String? = null, - @SerialName(value = "routing_number") val routingNumber: String? = null + @SerialName(value = "routing_number") val routingNumber: String? = null, + + // Whether the account is to be verified with microdeposits. + // This field isn't part of the API response and is being set later on. + val usesMicrodeposits: Boolean = true, ) : PaymentAccount() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionExtensions.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionExtensions.kt new file mode 100644 index 00000000000..295461d7279 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/FinancialConnectionsSessionExtensions.kt @@ -0,0 +1,35 @@ +package com.stripe.android.financialconnections.model + +/** + * Updates the [FinancialConnectionsSession] with any data from the [FinancialConnectionsSessionManifest] + * that's not already present. + */ +internal fun FinancialConnectionsSession.update( + manifest: FinancialConnectionsSessionManifest?, +): FinancialConnectionsSession { + val manualEntryUsesMicrodeposits = manifest?.manualEntryUsesMicrodeposits ?: false + return copy( + paymentAccount = paymentAccount?.setUsesMicrodepositsIfNeeded(manualEntryUsesMicrodeposits), + ) +} + +/** + * Updates the [FinancialConnectionsSession] with the [usesMicrodeposits] value if the linked account is + * a [BankAccount]. + */ +internal fun FinancialConnectionsSession.update( + usesMicrodeposits: Boolean, +): FinancialConnectionsSession { + return copy( + paymentAccount = paymentAccount?.setUsesMicrodepositsIfNeeded(usesMicrodeposits), + ) +} + +private fun PaymentAccount.setUsesMicrodepositsIfNeeded( + usesMicrodeposits: Boolean, +): PaymentAccount { + return when (this) { + is BankAccount -> copy(usesMicrodeposits = usesMicrodeposits) + is FinancialConnectionsAccount -> this + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt index a1408131924..1f899cc61aa 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModel.kt @@ -45,6 +45,7 @@ import com.stripe.android.financialconnections.launcher.FinancialConnectionsShee import com.stripe.android.financialconnections.model.BankAccount import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.model.update import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.destination @@ -371,10 +372,14 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( manualEntry = session.paymentAccount is BankAccount, ) ) + + val usesMicrodeposits = stateFlow.value.manualEntryUsesMicrodeposits + val updatedSession = session.update(usesMicrodeposits = usesMicrodeposits) + finishWithResult( Completed( - financialConnectionsSession = session, - token = session.parsedToken + financialConnectionsSession = updatedSession, + token = updatedSession.parsedToken, ) ) } @@ -385,7 +390,9 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor( } val result = if (instantDebits != null) { - Completed(instantDebits = instantDebits) + Completed( + instantDebits = instantDebits, + ) } else { Failed( error = UnclassifiedError( @@ -514,6 +521,7 @@ internal data class FinancialConnectionsSheetNativeState( val initialPane: Pane, val theme: Theme, val isLinkWithStripe: Boolean, + val manualEntryUsesMicrodeposits: Boolean, val elementsSessionContext: ElementsSessionContext?, ) { @@ -535,6 +543,7 @@ internal data class FinancialConnectionsSheetNativeState( theme = args.initialSyncResponse.manifest.theme?.toLocalTheme() ?: Theme.default, viewEffect = null, isLinkWithStripe = args.initialSyncResponse.manifest.isLinkWithStripe ?: false, + manualEntryUsesMicrodeposits = args.initialSyncResponse.manifest.manualEntryUsesMicrodeposits, elementsSessionContext = args.elementsSessionContext, ) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModelTest.kt index 298364e2add..659b8099b69 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/FinancialConnectionsSheetViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.core.exception.APIException @@ -27,6 +28,7 @@ import com.stripe.android.financialconnections.launcher.FinancialConnectionsShee import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult.Canceled import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult.Completed import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult.Failed +import com.stripe.android.financialconnections.model.BankAccount import com.stripe.android.financialconnections.model.FinancialConnectionsAccountFixtures import com.stripe.android.financialconnections.model.FinancialConnectionsAccountList import com.stripe.android.financialconnections.model.FinancialConnectionsSession @@ -658,6 +660,40 @@ class FinancialConnectionsSheetViewModelTest { } } + @Test + fun `Returns correct result when manual entry does not use microdeposits`() { + runTest { + // Given + whenever(fetchFinancialConnectionsSession(any())).thenReturn( + financialConnectionsSession().copy( + paymentAccount = BankAccount( + id = "id_1234", + last4 = "4242", + ) + ) + ) + + val viewModel = createViewModel( + defaultInitialState.copy( + manifest = syncResponse.manifest.copy(manualEntryUsesMicrodeposits = false), + webAuthFlowStatus = AuthFlowStatus.ON_EXTERNAL_ACTIVITY, + ) + ) + + viewModel.stateFlow.test { + assertThat(awaitItem().viewEffect).isNull() + + viewModel.handleOnNewIntent(successIntent()) + assertThat(awaitItem().webAuthFlowStatus).isEqualTo(AuthFlowStatus.NONE) + + val state = awaitItem() + val result = (state.viewEffect as FinishWithResult).result as Completed + val bankAccount = result.financialConnectionsSession?.paymentAccount as BankAccount + assertThat(bankAccount.usesMicrodeposits).isFalse() + } + } + } + @Test fun `init - when repository returns sync response, stores in state`() { runTest { diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt index 3ed68aa83f3..2d5cc79b11f 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/presentation/FinancialConnectionsSheetNativeViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures @@ -27,6 +28,7 @@ import com.stripe.android.financialconnections.launcher.FinancialConnectionsShee import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityResult.Failed import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetNativeActivityArgs import com.stripe.android.financialconnections.launcher.InstantDebitsResult +import com.stripe.android.financialconnections.model.BankAccount import com.stripe.android.financialconnections.model.FinancialConnectionsAccount import com.stripe.android.financialconnections.model.FinancialConnectionsSession.StatusDetails import com.stripe.android.financialconnections.model.FinancialConnectionsSession.StatusDetails.Cancelled @@ -314,6 +316,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest { initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT, theme = Theme.LinkLight, isLinkWithStripe = true, + manualEntryUsesMicrodeposits = false, elementsSessionContext = null, ) @@ -368,6 +371,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest { initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT, theme = Theme.LinkLight, isLinkWithStripe = true, + manualEntryUsesMicrodeposits = false, elementsSessionContext = null, ) @@ -420,6 +424,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest { initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT, theme = Theme.LinkLight, isLinkWithStripe = true, + manualEntryUsesMicrodeposits = false, elementsSessionContext = null, ) @@ -438,6 +443,51 @@ internal class FinancialConnectionsSheetNativeViewModelTest { assertThat(failedResult?.error?.message).isEqualTo("Something went wrong here") } + @Test + fun `Returns correct result when manual entry does not use microdeposits`() = runTest { + val session = financialConnectionsSessionWithNoMoreAccounts.copy( + paymentAccount = BankAccount( + id = "id_1234", + last4 = "4242", + ), + ) + + whenever(completeFinancialConnectionsSession(anyOrNull(), anyOrNull())).thenReturn( + CompleteFinancialConnectionsSession.Result( + session = session, + status = "completed", + ) + ) + + val initialState = FinancialConnectionsSheetNativeState( + webAuthFlow = WebAuthFlowState.Uninitialized, + firstInit = true, + configuration = configuration, + reducedBranding = false, + testMode = true, + viewEffect = null, + completed = false, + initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT, + theme = Theme.DefaultLight, + isLinkWithStripe = false, + manualEntryUsesMicrodeposits = false, + elementsSessionContext = null, + ) + + val viewModel = createViewModel(initialState) + + viewModel.stateFlow.test { + val state = awaitItem() + assertThat(state.viewEffect).isNull() + + nativeAuthFlowCoordinator().emit(Complete()) + + val result = (awaitItem().viewEffect as Finish).result as Completed + val bankAccount = result.financialConnectionsSession?.paymentAccount as BankAccount + assertThat(bankAccount.usesMicrodeposits).isFalse() + } + } + @After fun tearDown() { liveEvents.clear() diff --git a/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/AddPaymentMethodRequirement.kt b/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/AddPaymentMethodRequirement.kt index e1ad93ca99d..ccfd08993c5 100644 --- a/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/AddPaymentMethodRequirement.kt +++ b/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/AddPaymentMethodRequirement.kt @@ -50,11 +50,18 @@ internal enum class AddPaymentMethodRequirement { /** Requires a valid us bank verification method. */ ValidUsBankVerificationMethod { override fun isMetBy(metadata: PaymentMethodMetadata): Boolean { + // Verification method is always 'automatic' for deferred intents + val isDeferred = metadata.stripeIntent.clientSecret == null + return isDeferred || supportedVerificationMethodForNonDeferredIntent(metadata) + } + + private fun supportedVerificationMethodForNonDeferredIntent( + metadata: PaymentMethodMetadata, + ): Boolean { val pmo = metadata.stripeIntent.getPaymentMethodOptions()[USBankAccount.code] val verificationMethod = (pmo as? Map<*, *>)?.get("verification_method") as? String - val supportsVerificationMethod = verificationMethod in setOf("instant", "automatic") - val isDeferred = metadata.stripeIntent.clientSecret == null - return supportsVerificationMethod || isDeferred + val supportsVerificationMethod = verificationMethod in setOf("automatic", "instant", "instant_or_skip") + return supportsVerificationMethod } }, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt index 74528797b51..66b6ec454e4 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt @@ -374,7 +374,7 @@ internal class USBankAccountFormViewModel @Inject internal constructor( intentId = intentId, financialConnectionsSessionId = usBankAccountData.financialConnectionsSession.id, mandateText = buildMandateText(isVerifyWithMicrodeposits = true), - isVerifyingWithMicrodeposits = true, + isVerifyingWithMicrodeposits = paymentAccount.usesMicrodeposits, ) ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModelTest.kt index ca6e5a4159f..6ce653cc55a 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModelTest.kt @@ -152,7 +152,7 @@ class USBankAccountFormViewModelTest { viewModel.linkedAccount.test { skipItems(1) - viewModel.handleCollectBankAccountResult(mockUnverifiedBankAccount()) + viewModel.handleCollectBankAccountResult(mockManuallyEnteredBankAccount(usesMicrodeposits = true)) val screenState = awaitItem()?.screenState assertThat(screenState?.linkedBankAccount?.isVerifyingWithMicrodeposits).isTrue() @@ -173,7 +173,7 @@ class USBankAccountFormViewModelTest { @Test fun `Transitions to correct screen state when collecting an unverified bank account in custom flow`() = runTest { val viewModel = createViewModel(defaultArgs.copy(isCompleteFlow = false)) - val bankAccount = mockUnverifiedBankAccount() + val bankAccount = mockManuallyEnteredBankAccount(usesMicrodeposits = true) viewModel.linkedAccount.test { skipItems(1) @@ -1034,7 +1034,30 @@ class USBankAccountFormViewModelTest { viewModel.currentScreenState.test { assertThat(awaitItem().linkedBankAccount).isNull() - val unverifiedAccount = mockUnverifiedBankAccount() + val unverifiedAccount = mockManuallyEnteredBankAccount(usesMicrodeposits = true) + viewModel.handleCollectBankAccountResult(unverifiedAccount) + + val mandateCollectionViewState = awaitItem() + assertThat(mandateCollectionViewState.linkedBankAccount?.mandateText).isEqualTo(expectedResult) + } + } + + @Test + fun `Produces correct mandate text when skipping verification for manually entered account`() = runTest { + val viewModel = createViewModel() + + val expectedResult = USBankAccountTextBuilder.buildMandateAndMicrodepositsText( + merchantName = MERCHANT_NAME, + isVerifyingMicrodeposits = false, + isSaveForFutureUseSelected = false, + isSetupFlow = false, + isInstantDebits = false, + ) + + viewModel.currentScreenState.test { + assertThat(awaitItem().linkedBankAccount).isNull() + + val unverifiedAccount = mockManuallyEnteredBankAccount(usesMicrodeposits = false) viewModel.handleCollectBankAccountResult(unverifiedAccount) val mandateCollectionViewState = awaitItem() @@ -1446,7 +1469,7 @@ class USBankAccountFormViewModelTest { ) } - private fun mockUnverifiedBankAccount(): CollectBankAccountResultInternal.Completed { + private fun mockManuallyEnteredBankAccount(usesMicrodeposits: Boolean): CollectBankAccountResultInternal.Completed { val paymentIntent = mock() val financialConnectionsSession = mock() whenever(paymentIntent.id).thenReturn(defaultArgs.clientSecret) @@ -1456,7 +1479,8 @@ class USBankAccountFormViewModelTest { id = "123", last4 = "4567", bankName = "Test", - routingNumber = "123" + routingNumber = "123", + usesMicrodeposits = usesMicrodeposits, ) )