Skip to content

Commit

Permalink
Allow instant_or_skip verification for ACH (#9695)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tillh-stripe authored Nov 28, 2024
1 parent d7545c4 commit 4a18fb4
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 16 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
)
}
Expand All @@ -385,7 +390,9 @@ internal class FinancialConnectionsSheetNativeViewModel @Inject constructor(
}

val result = if (instantDebits != null) {
Completed(instantDebits = instantDebits)
Completed(
instantDebits = instantDebits,
)
} else {
Failed(
error = UnclassifiedError(
Expand Down Expand Up @@ -514,6 +521,7 @@ internal data class FinancialConnectionsSheetNativeState(
val initialPane: Pane,
val theme: Theme,
val isLinkWithStripe: Boolean,
val manualEntryUsesMicrodeposits: Boolean,
val elementsSessionContext: ElementsSessionContext?,
) {

Expand All @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -314,6 +316,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest {
initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT,
theme = Theme.LinkLight,
isLinkWithStripe = true,
manualEntryUsesMicrodeposits = false,
elementsSessionContext = null,
)

Expand Down Expand Up @@ -368,6 +371,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest {
initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT,
theme = Theme.LinkLight,
isLinkWithStripe = true,
manualEntryUsesMicrodeposits = false,
elementsSessionContext = null,
)

Expand Down Expand Up @@ -420,6 +424,7 @@ internal class FinancialConnectionsSheetNativeViewModelTest {
initialPane = FinancialConnectionsSessionManifest.Pane.CONSENT,
theme = Theme.LinkLight,
isLinkWithStripe = true,
manualEntryUsesMicrodeposits = false,
elementsSessionContext = null,
)

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1446,7 +1469,7 @@ class USBankAccountFormViewModelTest {
)
}

private fun mockUnverifiedBankAccount(): CollectBankAccountResultInternal.Completed {
private fun mockManuallyEnteredBankAccount(usesMicrodeposits: Boolean): CollectBankAccountResultInternal.Completed {
val paymentIntent = mock<PaymentIntent>()
val financialConnectionsSession = mock<FinancialConnectionsSession>()
whenever(paymentIntent.id).thenReturn(defaultArgs.clientSecret)
Expand All @@ -1456,7 +1479,8 @@ class USBankAccountFormViewModelTest {
id = "123",
last4 = "4567",
bankName = "Test",
routingNumber = "123"
routingNumber = "123",
usesMicrodeposits = usesMicrodeposits,
)
)

Expand Down

0 comments on commit 4a18fb4

Please sign in to comment.