Skip to content

Commit

Permalink
Enable bank payments in native Link (#10070)
Browse files Browse the repository at this point in the history
* Enable bank payments in native Link

GIT_VALID_PII_OVERRIDE

* Add end-to-end tests of bank payments in Link

* Update API

* Update legal terms

* Update screenshots

* Update detekt baseline

* Fix Link card brand edge case and address review feedback

- If Link card brand and ACH enabled, then use `bank_account`
- Rename `shareLinkCardBrand` to `sharePaymentDetails`
- Inject `Application` instead of `Context`

* Address code review feedback

Add `LinkCardBrandConfirmationModule`.

* Hide add payment method button if cards can't be used

* Update tests and add snapshot
  • Loading branch information
tillh-stripe authored Feb 12, 2025
1 parent ca68886 commit 51c5733
Show file tree
Hide file tree
Showing 39 changed files with 439 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import com.stripe.android.payments.core.analytics.ErrorReporter
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@JvmOverloads
internal fun DefaultFraudDetectionDataRepository(
fun DefaultFraudDetectionDataRepository(
context: Context,
workContext: CoroutineContext = Dispatchers.IO,
): DefaultFraudDetectionDataRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ package com.stripe.android.lpm

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stripe.android.BasePlaygroundTest
import com.stripe.android.paymentsheet.example.playground.settings.Country
import com.stripe.android.paymentsheet.example.playground.settings.CountrySettingsDefinition
import com.stripe.android.paymentsheet.example.playground.settings.DefaultBillingAddress
import com.stripe.android.paymentsheet.example.playground.settings.DefaultBillingAddressSettingsDefinition
import com.stripe.android.paymentsheet.example.playground.settings.LinkSettingsDefinition
import com.stripe.android.paymentsheet.example.playground.settings.LinkType
import com.stripe.android.paymentsheet.example.playground.settings.LinkTypeSettingsDefinition
import com.stripe.android.paymentsheet.example.playground.settings.SupportedPaymentMethodsSettingsDefinition
import com.stripe.android.test.core.TestParameters
import org.junit.Ignore
import org.junit.Test
Expand All @@ -21,4 +28,27 @@ internal class TestLink : BasePlaygroundTest() {
fun testLinkInlineCustom() {
testDriver.testLinkCustom(linkNewUser)
}

@Test
fun testLinkPaymentWithBankAccountInPaymentMethodMode() {
val testParameters = makeLinkTestParameters(passthroughMode = false)
testDriver.confirmWithBankAccountInLink(testParameters)
}

@Test
fun testLinkPaymentWithBankAccountInPassthroughMode() {
val testParameters = makeLinkTestParameters(passthroughMode = true)
testDriver.confirmWithBankAccountInLink(testParameters)
}

private fun makeLinkTestParameters(passthroughMode: Boolean): TestParameters {
return TestParameters.create(
paymentMethodCode = "card",
) { settings ->
settings[SupportedPaymentMethodsSettingsDefinition] = if (passthroughMode) "card" else "card,link"
settings[CountrySettingsDefinition] = Country.US
settings[LinkTypeSettingsDefinition] = LinkType.Native
settings[DefaultBillingAddressSettingsDefinition] = DefaultBillingAddress.On
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,51 @@ internal class PlaygroundTestDriver(
)
}

@OptIn(ExperimentalTestApi::class)
fun confirmWithBankAccountInLink(
testParameters: TestParameters,
) {
setup(testParameters)

launchComplete()

Espresso.onIdle()
composeTestRule.waitForIdle()

// Expect the OTP dialog
composeTestRule.waitUntilExactlyOneExists(hasTestTag("OTP-0"))

composeTestRule
.onNodeWithTag("OTP-0")
.performTextInput("000000")

composeTestRule.waitUntilExactlyOneExists(hasTestTag("collapsed_wallet_row_tag"))

composeTestRule
.onNodeWithTag("collapsed_wallet_row_tag")
.performClick()

composeTestRule
.onNodeWithText("Test Institution")
.performClick()

composeTestRule
.onNodeWithTag("wallet_screen_pay_button")
.performClick()

composeTestRule.waitForIdle()

// Skips the full screen payment animation in `PaymentSheet`
while (currentActivity !is PaymentSheetPlaygroundActivity) {
composeTestRule.mainClock.advanceTimeByFrame()
}

Espresso.onIdle()
composeTestRule.waitForIdle()

teardown()
}

fun confirmLinkBankPayment(
testParameters: TestParameters,
afterAuthorization: (Selectors, FieldPopulator) -> Unit = { _, _ -> },
Expand Down
16 changes: 16 additions & 0 deletions paymentsheet/api/paymentsheet.api
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,22 @@ public final class com/stripe/android/paymentelement/confirmation/gpay/GooglePay
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationDefinition$Result$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationDefinition$Result;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lcom/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationDefinition$Result;
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationOption$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationOption;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lcom/stripe/android/paymentelement/confirmation/link/LinkCardBrandConfirmationOption;
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/confirmation/link/LinkConfirmationOption$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/confirmation/link/LinkConfirmationOption;
Expand Down
3 changes: 2 additions & 1 deletion paymentsheet/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
<ID>LongMethod:USBankAccountForm.kt$@Composable private fun AccountDetailsForm( modifier: Modifier = Modifier, showCheckbox: Boolean, isProcessing: Boolean, bankName: String?, last4: String?, promoBadgeState: PromoBadgeState?, saveForFutureUseElement: SaveForFutureUseElement, onRemoveAccount: () -> Unit, )</ID>
<ID>LongMethod:USBankAccountForm.kt$@Composable private fun BillingDetailsForm( instantDebits: Boolean, formArgs: FormArguments, isProcessing: Boolean, isPaymentFlow: Boolean, nameController: TextFieldController, emailController: TextFieldController, phoneController: PhoneNumberController, addressController: AddressController, lastTextFieldIdentifier: IdentifierSpec?, sameAsShippingElement: SameAsShippingElement?, )</ID>
<ID>LongMethod:USBankAccountFormViewModel.kt$USBankAccountFormViewModel$private fun createNewPaymentSelection( resultIdentifier: ResultIdentifier, last4: String?, bankName: String?, billingDetails: PaymentMethod.BillingDetails, ): PaymentSelection.New.USBankAccount</ID>
<ID>MagicNumber:BaseSheetActivity.kt$BaseSheetActivity$30</ID>
<ID>MagicNumber:NewPaymentMethodTabLayoutUI.kt$.3f</ID>
<ID>MagicNumber:NewPaymentMethodTabLayoutUI.kt$.4f</ID>
<ID>MagicNumber:NewPaymentMethodTabLayoutUI.kt$.5f</ID>
Expand Down Expand Up @@ -71,6 +70,8 @@
<ID>MaxLineLength:SupportedPaymentMethod.kt$SupportedPaymentMethod$/** This describes the image in the LPM selector. These can be found internally [here](https://www.figma.com/file/2b9r3CJbyeVAmKi1VHV2h9/Mobile-Payment-Element?node-id=1128%3A0) */</ID>
<ID>MaxLineLength:USBankAccountFormViewModelTest.kt$USBankAccountFormViewModelTest$fun</ID>
<ID>MaximumLineLength:CardDefinition.kt$internal</ID>
<ID>SwallowedException:LinkCardBrandConfirmationDefinition.kt$e: Exception</ID>
<ID>TooGenericExceptionCaught:LinkCardBrandConfirmationDefinition.kt$e: Exception</ID>
<ID>TooManyFunctions:CustomerSheetEventReporter.kt$CustomerSheetEventReporter</ID>
<ID>TooManyFunctions:DefaultCustomerSheetEventReporter.kt$DefaultCustomerSheetEventReporter : CustomerSheetEventReporter</ID>
<ID>TooManyFunctions:DefaultEventReporter.kt$DefaultEventReporter : EventReporter</ID>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.link

import android.os.Parcelable
import com.stripe.android.model.LinkMode
import com.stripe.android.model.StripeIntent
import com.stripe.android.paymentsheet.addresselement.AddressDetails
import com.stripe.android.paymentsheet.state.PaymentElementLoader
Expand All @@ -20,6 +21,7 @@ internal data class LinkConfiguration(
val suppress2faModal: Boolean,
val initializationMode: PaymentElementLoader.InitializationMode,
val elementsSessionId: String,
val linkMode: LinkMode?,
) : Parcelable {
@Parcelize
data class CustomerInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.stripe.android.model.ConsumerSessionLookup
import com.stripe.android.model.ConsumerSignUpConsentAction
import com.stripe.android.model.EmailSource
import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.model.SharePaymentDetails
import com.stripe.android.payments.core.analytics.ErrorReporter
import com.stripe.android.paymentsheet.BuildConfig
import com.stripe.android.paymentsheet.model.amount
Expand Down Expand Up @@ -255,6 +256,21 @@ internal class DefaultLinkAccountManager @Inject constructor(
}
}

override suspend fun sharePaymentDetails(
paymentDetailsId: String,
expectedPaymentMethodType: String,
): Result<SharePaymentDetails> {
return runCatching {
requireNotNull(linkAccountHolder.linkAccount.value)
}.mapCatching { account ->
linkRepository.sharePaymentDetails(
paymentDetailsId = paymentDetailsId,
consumerSessionClientSecret = account.clientSecret,
expectedPaymentMethodType = expectedPaymentMethodType,
).getOrThrow()
}
}

private fun setAccount(
consumerSession: ConsumerSession,
publishableKey: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.stripe.android.model.ConsumerSession
import com.stripe.android.model.ConsumerSessionLookup
import com.stripe.android.model.EmailSource
import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.model.SharePaymentDetails
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

Expand Down Expand Up @@ -86,6 +87,11 @@ internal interface LinkAccountManager {
paymentMethodCreateParams: PaymentMethodCreateParams
): Result<LinkPaymentDetails>

suspend fun sharePaymentDetails(
paymentDetailsId: String,
expectedPaymentMethodType: String,
): Result<SharePaymentDetails>

fun setLinkAccountFromLookupResult(
lookup: ConsumerSessionLookup,
startSession: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.model.ConfirmPaymentIntentParams
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.model.LinkMode
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethod.Type.USBankAccount
import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.model.PaymentMethodOptionsParams
import com.stripe.android.model.wallets.Wallet
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption
import com.stripe.android.paymentelement.confirmation.link.LinkCardBrandConfirmationOption
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.R
import javax.inject.Inject
Expand Down Expand Up @@ -112,17 +115,26 @@ internal class DefaultLinkConfirmationHandler @Inject constructor(
linkAccount: LinkAccount,
cvc: String?
): ConfirmationHandler.Args {
return ConfirmationHandler.Args(
intent = configuration.stripeIntent,
confirmationOption = PaymentMethodConfirmationOption.New(
val confirmationOption = if (paymentDetails.useLinkCardBrandConfirmation) {
LinkCardBrandConfirmationOption(
paymentDetailsId = paymentDetails.id,
expectedPaymentMethodType = computeExpectedPaymentMethodType(),
)
} else {
PaymentMethodConfirmationOption.New(
createParams = createPaymentMethodCreateParams(
selectedPaymentDetails = paymentDetails,
linkAccount = linkAccount,
cvc = cvc
),
optionsParams = null,
shouldSave = false
),
)
}

return ConfirmationHandler.Args(
intent = configuration.stripeIntent,
confirmationOption = confirmationOption,
appearance = PaymentSheet.Appearance(),
initializationMode = configuration.initializationMode,
shippingDetails = configuration.shippingDetails
Expand Down Expand Up @@ -172,6 +184,20 @@ internal class DefaultLinkConfirmationHandler @Inject constructor(
)
}

private val ConsumerPaymentDetails.PaymentDetails.useLinkCardBrandConfirmation: Boolean
get() = type == ConsumerPaymentDetails.BankAccount.TYPE && configuration.passthroughModeEnabled

private fun computeExpectedPaymentMethodType(): String {
val canAcceptACH = USBankAccount.code in configuration.stripeIntent.paymentMethodTypes
val isLinkCardBrand = configuration.linkMode == LinkMode.LinkCardBrand

return if (isLinkCardBrand && !canAcceptACH) {
ConsumerPaymentDetails.Card.TYPE
} else {
ConsumerPaymentDetails.BankAccount.TYPE
}
}

class Factory @Inject constructor(
private val configuration: LinkConfiguration,
private val logger: Logger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.stripe.android.link.analytics.LinkEventsReporter
import com.stripe.android.link.confirmation.LinkConfirmationHandler
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.paymentelement.confirmation.injection.DefaultConfirmationModule
import com.stripe.android.paymentelement.confirmation.link.LinkCardBrandConfirmationModule
import com.stripe.android.payments.core.analytics.ErrorReporter
import com.stripe.android.payments.core.injection.STATUS_BAR_COLOR
import dagger.BindsInstance
Expand All @@ -34,6 +35,7 @@ internal annotation class NativeLinkScope
LinkViewModelModule::class,
ApplicationIdModule::class,
DefaultConfirmationModule::class,
LinkCardBrandConfirmationModule::class,
]
)
internal interface NativeLinkComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import com.stripe.android.link.repositories.LinkRepository
import com.stripe.android.networking.StripeApiRepository
import com.stripe.android.networking.StripeRepository
import com.stripe.android.paymentelement.confirmation.ALLOWS_MANUAL_CONFIRMATION
import com.stripe.android.paymentelement.confirmation.ConfirmationDefinition
import com.stripe.android.paymentelement.confirmation.link.LinkCardBrandConfirmationDefinition
import com.stripe.android.payments.core.analytics.ErrorReporter
import com.stripe.android.payments.core.analytics.RealErrorReporter
import com.stripe.android.payments.core.injection.PRODUCT_USAGE
Expand All @@ -52,6 +54,7 @@ import com.stripe.attestation.IntegrityRequestManager
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import kotlinx.coroutines.Dispatchers
import javax.inject.Named
import kotlin.coroutines.CoroutineContext
Expand Down Expand Up @@ -204,5 +207,16 @@ internal interface NativeLinkModule {
fun provideIntegrityStandardRequestManager(
context: Application
): IntegrityRequestManager = createIntegrityStandardRequestManager(context)

@JvmSuppressWildcards
@Provides
@IntoSet
fun providesLinkCardBrandConfirmationDefinition(
linkAccountManager: DefaultLinkAccountManager
): ConfirmationDefinition<*, *, *, *> {
return LinkCardBrandConfirmationDefinition(
linkAccountManager = linkAccountManager,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ import com.stripe.android.model.StripeIntent
* The supported payment methods are read from [StripeIntent.linkFundingSources], and fallback to
* card only if the list is empty or none of them is valid.
*/
internal fun StripeIntent.supportedPaymentMethodTypes(linkAccount: LinkAccount) =
internal fun StripeIntent.supportedPaymentMethodTypes(linkAccount: LinkAccount): Set<String> {
if (!isLiveMode && linkAccount.email.contains("+multiple_funding_sources@")) {
supportedPaymentMethodTypes
} else {
linkFundingSources.filter { supportedPaymentMethodTypes.contains(it) }
.takeIf { it.isNotEmpty() }?.toSet()
?: setOf(ConsumerPaymentDetails.Card.TYPE)
return supportedPaymentMethodTypes
}

val allowedFundingSources = linkFundingSources.filter { it in supportedPaymentMethodTypes }
return allowedFundingSources.toSet().takeIf { it.isNotEmpty() } ?: setOf(ConsumerPaymentDetails.Card.TYPE)
}

private val supportedPaymentMethodTypes = setOf(
ConsumerPaymentDetails.Card.TYPE,
ConsumerPaymentDetails.BankAccount.TYPE,
ConsumerPaymentDetails.Passthrough.TYPE
)
Loading

0 comments on commit 51c5733

Please sign in to comment.