diff --git a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundViewModel.kt b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundViewModel.kt index 4991b1548c9..bf4e56f1e72 100644 --- a/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundViewModel.kt +++ b/financial-connections-example/src/main/java/com/stripe/android/financialconnections/example/FinancialConnectionsPlaygroundViewModel.kt @@ -19,6 +19,7 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsEve import com.stripe.android.financialconnections.example.data.BackendRepository import com.stripe.android.financialconnections.example.data.Settings import com.stripe.android.financialconnections.example.settings.ConfirmIntentSetting +import com.stripe.android.financialconnections.example.settings.EmailSetting import com.stripe.android.financialconnections.example.settings.ExperienceSetting import com.stripe.android.financialconnections.example.settings.FinancialConnectionsPlaygroundUrlHelper import com.stripe.android.financialconnections.example.settings.FlowSetting @@ -136,6 +137,9 @@ internal class FinancialConnectionsPlaygroundViewModel( amount = it.amount, currency = it.currency, linkMode = LinkMode.LinkPaymentMethod, + billingDetails = ElementsSessionContext.BillingDetails( + email = settings.get().selectedOption, + ), ), experience = settings.get().selectedOption, integrationType = settings.get().selectedOption, diff --git a/financial-connections/api/financial-connections.api b/financial-connections/api/financial-connections.api index 1ab45eb3647..f30db3d0abd 100644 --- a/financial-connections/api/financial-connections.api +++ b/financial-connections/api/financial-connections.api @@ -145,6 +145,22 @@ public final class com/stripe/android/financialconnections/FinancialConnectionsS public synthetic fun newArray (I)[Ljava/lang/Object; } +public final class com/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails$Address$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails$Address; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails$Address; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class com/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$BillingDetails; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class com/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/FinancialConnectionsSheet$ElementsSessionContext; diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheet.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheet.kt index 40192823c0b..f058c20df70 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheet.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/FinancialConnectionsSheet.kt @@ -46,6 +46,7 @@ class FinancialConnectionsSheet internal constructor( val amount: Long?, val currency: String?, val linkMode: LinkMode?, + val billingDetails: BillingDetails?, ) : Parcelable { val paymentIntentId: String? @@ -69,6 +70,27 @@ class FinancialConnectionsSheet internal constructor( @Parcelize data object DeferredIntent : InitializationMode } + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + @Parcelize + data class BillingDetails( + val name: String? = null, + val phone: String? = null, + val email: String? = null, + val address: Address? = null, + ) : Parcelable { + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + @Parcelize + data class Address( + val line1: String? = null, + val line2: String? = null, + val postalCode: String? = null, + val city: String? = null, + val state: String? = null, + val country: String? = null, + ) : Parcelable + } } /** diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/CreateInstantDebitsResult.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/CreateInstantDebitsResult.kt index d7757da2028..45cc40d67c8 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/CreateInstantDebitsResult.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/CreateInstantDebitsResult.kt @@ -26,13 +26,17 @@ internal class RealCreateInstantDebitsResult @Inject constructor( bankAccountId: String, ): InstantDebitsResult { val consumerSession = consumerSessionProvider.provideConsumerSession() + val clientSecret = requireNotNull(consumerSession?.clientSecret) { "Consumer session client secret cannot be null" } + val billingDetails = elementsSessionContext?.billingDetails + val response = consumerRepository.createPaymentDetails( consumerSessionClientSecret = clientSecret, bankAccountId = bankAccountId, + billingDetails = billingDetails, ) val paymentDetails = response.paymentDetails.filterIsInstance().first() @@ -42,11 +46,13 @@ internal class RealCreateInstantDebitsResult @Inject constructor( paymentDetailsId = paymentDetails.id, consumerSessionClientSecret = clientSecret, expectedPaymentMethodType = elementsSessionContext.linkMode.expectedPaymentMethodType, + billingPhone = elementsSessionContext.billingDetails?.phone, ).paymentMethodId } else { repository.createPaymentMethod( paymentDetailsId = paymentDetails.id, consumerSessionClientSecret = clientSecret, + billingDetails = billingDetails, ).id } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepository.kt index db77e3939ad..86cb1d141a7 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepository.kt @@ -3,9 +3,11 @@ package com.stripe.android.financialconnections.repository import com.stripe.android.core.Logger import com.stripe.android.core.frauddetection.FraudDetectionDataRepository import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext +import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails import com.stripe.android.financialconnections.domain.IsLinkWithStripe import com.stripe.android.financialconnections.repository.api.FinancialConnectionsConsumersApiService import com.stripe.android.financialconnections.repository.api.ProvideApiRequestOptions +import com.stripe.android.financialconnections.utils.toConsumerBillingAddressParams import com.stripe.android.model.AttachConsumerToLinkAccountSession import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.ConsumerPaymentDetailsCreateParams @@ -57,12 +59,14 @@ internal interface FinancialConnectionsConsumerSessionRepository { suspend fun createPaymentDetails( bankAccountId: String, consumerSessionClientSecret: String, + billingDetails: BillingDetails?, ): ConsumerPaymentDetails suspend fun sharePaymentDetails( paymentDetailsId: String, consumerSessionClientSecret: String, expectedPaymentMethodType: String, + billingPhone: String?, ): SharePaymentDetails companion object { @@ -200,12 +204,15 @@ private class FinancialConnectionsConsumerSessionRepositoryImpl( override suspend fun createPaymentDetails( bankAccountId: String, - consumerSessionClientSecret: String + consumerSessionClientSecret: String, + billingDetails: BillingDetails?, ): ConsumerPaymentDetails { return consumersApiService.createPaymentDetails( consumerSessionClientSecret = consumerSessionClientSecret, paymentDetailsCreateParams = ConsumerPaymentDetailsCreateParams.BankAccount( bankAccountId = bankAccountId, + billingAddress = billingDetails?.toConsumerBillingAddressParams(), + billingEmailAddress = billingDetails?.email, ), requestSurface = requestSurface, requestOptions = provideApiRequestOptions(useConsumerPublishableKey = true), @@ -215,7 +222,8 @@ private class FinancialConnectionsConsumerSessionRepositoryImpl( override suspend fun sharePaymentDetails( paymentDetailsId: String, consumerSessionClientSecret: String, - expectedPaymentMethodType: String + expectedPaymentMethodType: String, + billingPhone: String?, ): SharePaymentDetails { val fraudDetectionData = fraudDetectionDataRepository.getCached()?.params.orEmpty() @@ -223,6 +231,7 @@ private class FinancialConnectionsConsumerSessionRepositoryImpl( consumerSessionClientSecret = consumerSessionClientSecret, paymentDetailsId = paymentDetailsId, expectedPaymentMethodType = expectedPaymentMethodType, + billingPhone = elementsSessionContext?.billingDetails?.phone?.takeIf { it.isNotBlank() }, requestSurface = requestSurface, requestOptions = provideApiRequestOptions(useConsumerPublishableKey = false), extraParams = fraudDetectionData, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsRepository.kt index 3f608250e97..a455bee106f 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsRepository.kt @@ -6,6 +6,7 @@ import com.stripe.android.core.exception.AuthenticationException import com.stripe.android.core.exception.InvalidRequestException import com.stripe.android.core.frauddetection.FraudDetectionDataRepository import com.stripe.android.core.networking.ApiRequest +import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails import com.stripe.android.financialconnections.model.FinancialConnectionsAccountList import com.stripe.android.financialconnections.model.FinancialConnectionsSession import com.stripe.android.financialconnections.model.GetFinancialConnectionsAcccountsParams @@ -15,6 +16,7 @@ import com.stripe.android.financialconnections.network.FinancialConnectionsReque import com.stripe.android.financialconnections.network.NetworkConstants import com.stripe.android.financialconnections.repository.api.ProvideApiRequestOptions import com.stripe.android.financialconnections.utils.filterNotNullValues +import com.stripe.android.financialconnections.utils.toApiParams import javax.inject.Inject internal interface FinancialConnectionsRepository { @@ -57,6 +59,7 @@ internal interface FinancialConnectionsRepository { suspend fun createPaymentMethod( paymentDetailsId: String, consumerSessionClientSecret: String, + billingDetails: BillingDetails?, ): PaymentMethod } @@ -135,26 +138,29 @@ internal class FinancialConnectionsRepositoryImpl @Inject constructor( override suspend fun createPaymentMethod( paymentDetailsId: String, - consumerSessionClientSecret: String + consumerSessionClientSecret: String, + billingDetails: BillingDetails?, ): PaymentMethod { - val credentials = mapOf( - "consumer_session_client_secret" to consumerSessionClientSecret, - ) - - val params = mapOf( + val linkParams = mapOf( "type" to "link", "link" to mapOf( - "credentials" to credentials, + "credentials" to mapOf( + "consumer_session_client_secret" to consumerSessionClientSecret, + ), "payment_details_id" to paymentDetailsId, ), ) + val billingParams = billingDetails?.let { + mapOf("billing_details" to billingDetails.toApiParams()) + }.orEmpty() + val fraudDetectionParams = fraudDetectionDataRepository.getCached()?.params.orEmpty() val request = apiRequestFactory.createPost( url = paymentMethodsUrl, options = provideApiRequestOptions(useConsumerPublishableKey = false), - params = params + fraudDetectionParams, + params = linkParams + billingParams + fraudDetectionParams, ) return requestExecutor.execute( diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/BillingDetailsExtensions.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/BillingDetailsExtensions.kt new file mode 100644 index 00000000000..03b985f4b9d --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/utils/BillingDetailsExtensions.kt @@ -0,0 +1,55 @@ +package com.stripe.android.financialconnections.utils + +import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails + +/** + * Creates API params for use with the Stripe core API. + * + * These params include the phone number and a nested address object. + */ +internal fun BillingDetails.toApiParams(): Map { + val addressParams = address?.let { address -> + buildMap { + address.line1?.let { put("line1", it) } + address.line2?.let { put("line2", it) } + address.postalCode?.let { put("postal_code", it) } + address.city?.let { put("city", it) } + address.state?.let { put("state", it) } + address.country?.let { put("country", it) } + }.filterValues { + it.isNotBlank() + } + } + return mapOf( + "name" to name, + "email" to email, + "phone" to phone, + "address" to addressParams, + ).filterNotNullValues() +} + +/** + * Creates API params for use with the consumer API. + * + * These params don't include the phone number and flatten the address. + */ +internal fun BillingDetails.toConsumerBillingAddressParams(): Map { + val contactParams = buildMap { + name?.let { put("name", it) } + }.filter { entry -> + entry.value.isNotBlank() + } + + val addressParams = buildMap { + address?.line1?.let { put("line_1", it) } + address?.line2?.let { put("line_2", it) } + address?.postalCode?.let { put("postal_code", it) } + address?.city?.let { put("locality", it) } + address?.state?.let { put("administrative_area", it) } + address?.country?.let { put("country_code", it) } + }.filterValues { + it.isNotBlank() + } + + return contactParams + addressParams +} 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 176c64dfdbc..8876b393112 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 @@ -149,6 +149,7 @@ class FinancialConnectionsSheetViewModelTest { amount = 123, currency = "usd", linkMode = LinkMode.LinkPaymentMethod, + billingDetails = null, ), ) ) @@ -180,6 +181,7 @@ class FinancialConnectionsSheetViewModelTest { amount = 123, currency = "usd", linkMode = null, + billingDetails = null, ), ) ) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/RealCreateInstantDebitsResultTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/RealCreateInstantDebitsResultTest.kt index 7a7b2a8c208..2dc567a8847 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/RealCreateInstantDebitsResultTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/RealCreateInstantDebitsResultTest.kt @@ -1,6 +1,7 @@ package com.stripe.android.financialconnections.domain import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext +import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails import com.stripe.android.financialconnections.model.PaymentMethod import com.stripe.android.financialconnections.repository.CachedConsumerSession import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository @@ -11,6 +12,7 @@ import com.stripe.android.model.SharePaymentDetails import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -38,6 +40,7 @@ class RealCreateInstantDebitsResultTest { paymentDetailsId = "ba_1234", consumerSessionClientSecret = "clientSecret", expectedPaymentMethodType = "card", + billingPhone = null, ) verifyNoInteractions(repository) @@ -62,6 +65,7 @@ class RealCreateInstantDebitsResultTest { verify(repository).createPaymentMethod( paymentDetailsId = "ba_1234", consumerSessionClientSecret = "clientSecret", + billingDetails = null, ) } @@ -84,6 +88,83 @@ class RealCreateInstantDebitsResultTest { verify(repository).createPaymentMethod( paymentDetailsId = "ba_1234", consumerSessionClientSecret = "clientSecret", + billingDetails = null, + ) + } + + @Test + fun `Passes along billing details to createPaymentMethod if available`() = runTest { + val consumerRepository = makeConsumerSessionRepository() + val repository = makeRepository() + + val billingDetails = BillingDetails( + name = "Some name", + phone = "+15555555555", + email = "test@test.com", + address = BillingDetails.Address( + city = "San Francisco", + country = "US", + line1 = "123 Main St", + line2 = "Apt 4", + postalCode = "94111", + state = "CA", + ), + ) + + val createInstantDebitResult = RealCreateInstantDebitsResult( + consumerRepository = consumerRepository, + repository = repository, + consumerSessionProvider = { makeCachedConsumerSession() }, + elementsSessionContext = makeElementsSessionContext( + linkMode = null, + billingDetails = billingDetails, + ), + ) + + createInstantDebitResult("bank_account_id_001") + + verify(repository).createPaymentMethod( + paymentDetailsId = "ba_1234", + consumerSessionClientSecret = "clientSecret", + billingDetails = billingDetails, + ) + } + + @Test + fun `Passes along billing details to sharePaymentDetails if available`() = runTest { + val consumerRepository = makeConsumerSessionRepository() + val repository = makeRepository() + + val billingDetails = BillingDetails( + name = "Some name", + phone = "+15555555555", + address = BillingDetails.Address( + city = "San Francisco", + country = "US", + line1 = "123 Main St", + line2 = "Apt 4", + postalCode = "94111", + state = "CA", + ), + ) + + val createInstantDebitResult = RealCreateInstantDebitsResult( + consumerRepository = consumerRepository, + repository = repository, + consumerSessionProvider = { makeCachedConsumerSession() }, + elementsSessionContext = makeElementsSessionContext( + linkMode = LinkMode.LinkCardBrand, + billingDetails = billingDetails, + ), + ) + + createInstantDebitResult("bank_account_id_001") + + verify(consumerRepository).sharePaymentDetails( + paymentDetailsId = "ba_1234", + consumerSessionClientSecret = "clientSecret", + expectedPaymentMethodType = "card", + billingPhone = "+15555555555", ) } @@ -103,8 +184,8 @@ class RealCreateInstantDebitsResultTest { ) return mock { - onBlocking { createPaymentDetails(any(), any()) } doReturn consumerPaymentDetails - onBlocking { sharePaymentDetails(any(), any(), any()) } doReturn sharePaymentDetails + onBlocking { createPaymentDetails(any(), any(), anyOrNull()) } doReturn consumerPaymentDetails + onBlocking { sharePaymentDetails(any(), any(), any(), anyOrNull()) } doReturn sharePaymentDetails } } @@ -114,7 +195,7 @@ class RealCreateInstantDebitsResultTest { ) return mock { - onBlocking { createPaymentMethod(any(), any()) } doReturn paymentMethod + onBlocking { createPaymentMethod(any(), any(), anyOrNull()) } doReturn paymentMethod } } @@ -130,12 +211,14 @@ class RealCreateInstantDebitsResultTest { private fun makeElementsSessionContext( linkMode: LinkMode?, + billingDetails: BillingDetails? = null, ): ElementsSessionContext { return ElementsSessionContext( initializationMode = ElementsSessionContext.InitializationMode.PaymentIntent("pi_123"), amount = 100L, currency = "usd", linkMode = linkMode, + billingDetails = billingDetails, ) } } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsRepository.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsRepository.kt index f723ce65d67..96d9d3c7061 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsRepository.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsRepository.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.networking +import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails import com.stripe.android.financialconnections.financialConnectionsSessionWithNoMoreAccounts import com.stripe.android.financialconnections.model.FinancialConnectionsAccountList import com.stripe.android.financialconnections.model.FinancialConnectionsSession @@ -44,7 +45,8 @@ internal class FakeFinancialConnectionsRepository : FinancialConnectionsReposito override suspend fun createPaymentMethod( paymentDetailsId: String, - consumerSessionClientSecret: String + consumerSessionClientSecret: String, + billingDetails: BillingDetails?, ): PaymentMethod { return createPaymentMethod() } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepositoryImplTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepositoryImplTest.kt index 44197ad4490..c2a5b3be125 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepositoryImplTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/repository/FinancialConnectionsConsumerSessionRepositoryImplTest.kt @@ -163,6 +163,7 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { amount = 1234, currency = "cad", linkMode = LinkMode.LinkPaymentMethod, + billingDetails = null, ) ) @@ -427,6 +428,7 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { consumerSessionClientSecret = anyOrNull(), paymentDetailsId = anyOrNull(), expectedPaymentMethodType = anyOrNull(), + billingPhone = anyOrNull(), requestSurface = anyOrNull(), requestOptions = anyOrNull(), extraParams = eq(fraudParams.params), @@ -441,6 +443,7 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { consumerSessionClientSecret = consumerSessionClientSecret, paymentDetailsId = "pd_123", expectedPaymentMethodType = "card", + billingPhone = null, ) verify(fraudDetectionDataRepository, never()).getLatest() diff --git a/payments-model/src/main/java/com/stripe/android/model/ConsumerPaymentDetailsCreateParams.kt b/payments-model/src/main/java/com/stripe/android/model/ConsumerPaymentDetailsCreateParams.kt index b3ecb7eb99b..caedfcd1c12 100644 --- a/payments-model/src/main/java/com/stripe/android/model/ConsumerPaymentDetailsCreateParams.kt +++ b/payments-model/src/main/java/com/stripe/android/model/ConsumerPaymentDetailsCreateParams.kt @@ -77,15 +77,27 @@ sealed interface ConsumerPaymentDetailsCreateParams : StripeParamsModel, Parcela @Parcelize data class BankAccount( private val bankAccountId: String, + private val billingAddress: Map?, + private val billingEmailAddress: String?, ) : ConsumerPaymentDetailsCreateParams { override fun toParamMap(): Map { - return mapOf( + val billingParams = buildMap { + billingEmailAddress?.let { put("billing_email_address", it) } + + if (!billingAddress.isNullOrEmpty()) { + put("billing_address", billingAddress) + } + } + + val accountParams = mapOf( "type" to "bank_account", "bank_account" to mapOf( "account" to bankAccountId, ), ) + + return accountParams + billingParams } } } diff --git a/payments-model/src/main/java/com/stripe/android/repository/ConsumersApiService.kt b/payments-model/src/main/java/com/stripe/android/repository/ConsumersApiService.kt index 8f6c78fc5b6..a08fb37cbb9 100644 --- a/payments-model/src/main/java/com/stripe/android/repository/ConsumersApiService.kt +++ b/payments-model/src/main/java/com/stripe/android/repository/ConsumersApiService.kt @@ -86,6 +86,7 @@ interface ConsumersApiService { consumerSessionClientSecret: String, paymentDetailsId: String, expectedPaymentMethodType: String, + billingPhone: String?, requestSurface: String, requestOptions: ApiRequest.Options, extraParams: Map, @@ -295,6 +296,7 @@ class ConsumersApiServiceImpl( consumerSessionClientSecret: String, paymentDetailsId: String, expectedPaymentMethodType: String, + billingPhone: String?, requestSurface: String, requestOptions: ApiRequest.Options, extraParams: Map, @@ -312,6 +314,7 @@ class ConsumersApiServiceImpl( "credentials" to mapOf( "consumer_session_client_secret" to consumerSessionClientSecret ), + "billing_phone" to billingPhone, ) + extraParams, ), responseJsonParser = SharePaymentDetailsJsonParser, diff --git a/paymentsheet/detekt-baseline.xml b/paymentsheet/detekt-baseline.xml index 8a5ebbdd34a..5ba6b2fa0a1 100644 --- a/paymentsheet/detekt-baseline.xml +++ b/paymentsheet/detekt-baseline.xml @@ -29,7 +29,6 @@ LongMethod:EditPaymentMethod.kt$@Composable internal fun EditPaymentMethodUi( viewState: EditPaymentMethodViewState, viewActionHandler: (action: EditPaymentMethodViewAction) -> Unit, modifier: Modifier = Modifier ) LongMethod:FormViewModelTest.kt$FormViewModelTest$@Test fun `Verify params are set when element address fields are complete`() LongMethod:FormViewModelTest.kt$FormViewModelTest$@Test fun `Verify params are set when required address fields are complete`() - LongMethod:PaymentMethodMetadataTest.kt$PaymentMethodMetadataTest$@Test fun `should create metadata properly with elements session response, payment sheet config, and data specs`() LongMethod:PaymentMethodVerticalLayoutInteractor.kt$DefaultPaymentMethodVerticalLayoutInteractor.Companion$fun create( viewModel: BaseSheetViewModel, paymentMethodMetadata: PaymentMethodMetadata, customerStateHolder: CustomerStateHolder, ): PaymentMethodVerticalLayoutInteractor LongMethod:PaymentOptionFactory.kt$PaymentOptionFactory$fun create(selection: PaymentSelection): PaymentOption LongMethod:PaymentSheetConfigurationKtx.kt$internal fun PaymentSheet.Appearance.parseAppearance() @@ -66,6 +65,7 @@ MaxLineLength:PaymentSheet.kt$PaymentSheet.IntentConfiguration.SetupFutureUse$* MaxLineLength:PrimaryButtonTest.kt$PrimaryButtonTest$primaryButton.setAppearanceConfiguration(StripeThemeDefaults.primaryButtonStyle, ColorStateList.valueOf(Color.BLACK)) 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) */ + MaxLineLength:USBankAccountFormViewModelTest.kt$USBankAccountFormViewModelTest$fun MaxLineLength:USBankAccountFormViewModelTest.kt$USBankAccountFormViewModelTest$viewModel.handlePrimaryButtonClick(currentScreenState as USBankAccountFormScreenState.VerifyWithMicrodeposits) MaximumLineLength:CardDefinition.kt$internal ThrowsCount:PaymentSheetConfigurationKtx.kt$internal fun PaymentSheet.Configuration.validate() 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 706cac65fcb..34839b067d1 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 @@ -12,6 +12,7 @@ import androidx.lifecycle.viewmodel.CreationExtras import com.stripe.android.PaymentConfiguration import com.stripe.android.core.strings.ResolvableString import com.stripe.android.core.strings.resolvableString +import com.stripe.android.core.utils.FeatureFlags import com.stripe.android.core.utils.requireApplication import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext import com.stripe.android.financialconnections.model.BankAccount @@ -526,6 +527,35 @@ internal class USBankAccountFormViewModel @Inject internal constructor( amount = args.formArgs.amount?.value, currency = args.formArgs.amount?.currencyCode, linkMode = args.linkMode, + billingDetails = if (FeatureFlags.instantDebitsBillingDetails.isEnabled) { + makeElementsSessionContextBillingDetails() + } else { + null + }, + ) + } + + private fun makeElementsSessionContextBillingDetails(): ElementsSessionContext.BillingDetails { + val attachDefaultsToPaymentMethod = collectionConfiguration.attachDefaultsToPaymentMethod + val name = name.value.takeIf { collectingName || attachDefaultsToPaymentMethod } + val email = email.value.takeIf { collectingEmail || attachDefaultsToPaymentMethod } + val phone = phone.value.takeIf { collectingPhone || attachDefaultsToPaymentMethod } + val address = address.value.takeIf { collectingAddress || attachDefaultsToPaymentMethod } + + return ElementsSessionContext.BillingDetails( + name = name, + email = email, + phone = phone?.let { phoneController.getE164PhoneNumber(it) }, + address = address?.let { + ElementsSessionContext.BillingDetails.Address( + line1 = it.line1, + line2 = it.line2, + postalCode = it.postalCode, + city = it.city, + state = it.state, + country = it.country, + ) + }, ) } 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 0bd6bddfb3a..c74056f73a3 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 @@ -7,6 +7,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.PaymentConfiguration import com.stripe.android.core.strings.resolvableString +import com.stripe.android.core.utils.FeatureFlags import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext import com.stripe.android.financialconnections.model.BankAccount import com.stripe.android.financialconnections.model.FinancialConnectionsAccount @@ -28,6 +29,7 @@ import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConf import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments +import com.stripe.android.testing.FeatureFlagTestRule import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.cbc.CardBrandChoiceEligibility import com.stripe.android.uicore.elements.IdentifierSpec @@ -36,9 +38,11 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.junit.Rule import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -80,6 +84,12 @@ class USBankAccountFormViewModelTest { private val mockCollectBankAccountLauncher = mock() private val savedStateHandle = SavedStateHandle() + @get:Rule + val instantDebitsBillingDetailsFeatureRule = FeatureFlagTestRule( + featureFlag = FeatureFlags.instantDebitsBillingDetails, + isEnabled = true, + ) + @BeforeTest fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -825,6 +835,10 @@ class USBankAccountFormViewModelTest { amount = 5099, currency = "usd", linkMode = LinkMode.LinkPaymentMethod, + billingDetails = ElementsSessionContext.BillingDetails( + name = "Jenny Rose", + email = "email@email.com", + ), ), ) ), @@ -874,6 +888,10 @@ class USBankAccountFormViewModelTest { amount = null, currency = null, linkMode = LinkMode.LinkPaymentMethod, + billingDetails = ElementsSessionContext.BillingDetails( + name = "Jenny Rose", + email = "email@email.com", + ), ), ) ), @@ -1081,6 +1099,10 @@ class USBankAccountFormViewModelTest { amount = 5099, currency = "usd", linkMode = null, + billingDetails = ElementsSessionContext.BillingDetails( + name = "Some Name", + email = "email@email.com", + ), ), ) ), @@ -1115,6 +1137,9 @@ class USBankAccountFormViewModelTest { amount = 5099, currency = "usd", linkMode = LinkMode.LinkCardBrand, + billingDetails = ElementsSessionContext.BillingDetails( + email = "email@email.com", + ), ), ) ), @@ -1322,6 +1347,127 @@ class USBankAccountFormViewModelTest { expectedAllowRedisplay = PaymentMethod.AllowRedisplay.ALWAYS, ) + @Test + fun `Creates correct ElementsSessionContext if attaching defaults to PaymentMethod`() = runTest { + val args = createArgsForBillingDetailsCollectionInInstantDebits( + collectName = false, + collectEmail = true, + collectPhone = false, + collectAddress = false, + attachDefaultsToPaymentMethod = true, + ) + + val elementsSessionContext = testElementsSessionContextGeneration(viewModelArgs = args) + + assertThat(elementsSessionContext?.billingDetails).isEqualTo( + ElementsSessionContext.BillingDetails( + name = "Jenny Rose", + email = "email@email.com", + phone = "+13105551234", + address = ElementsSessionContext.BillingDetails.Address( + line1 = "123 Main Street", + line2 = "Apt 456", + city = "San Francisco", + state = "CA", + postalCode = "94111", + country = "US", + ), + ) + ) + } + + @Test + fun `Creates correct ElementsSessionContext if not attaching defaults to PaymentMethod`() = runTest { + val args = createArgsForBillingDetailsCollectionInInstantDebits( + collectName = false, + collectEmail = true, + collectPhone = false, + collectAddress = false, + attachDefaultsToPaymentMethod = false, + ) + + val elementsSessionContext = testElementsSessionContextGeneration(viewModelArgs = args) + + assertThat(elementsSessionContext?.billingDetails).isEqualTo( + ElementsSessionContext.BillingDetails( + email = "email@email.com", + ) + ) + } + + @Test + fun `Creates correct ElementsSessionContext if not attaching defaults to PaymentMethod with specific collection`() = runTest { + val args = createArgsForBillingDetailsCollectionInInstantDebits( + collectName = false, + collectEmail = true, + collectPhone = true, + collectAddress = false, + attachDefaultsToPaymentMethod = false, + ) + + val elementsSessionContext = testElementsSessionContextGeneration(viewModelArgs = args) + + assertThat(elementsSessionContext?.billingDetails).isEqualTo( + ElementsSessionContext.BillingDetails( + email = "email@email.com", + phone = "+13105551234", + ) + ) + } + + private fun testElementsSessionContextGeneration( + viewModelArgs: USBankAccountFormViewModel.Args, + ): ElementsSessionContext? { + val viewModel = createViewModel(viewModelArgs) + viewModel.collectBankAccountLauncher = mockCollectBankAccountLauncher + + val screenState = viewModel.currentScreenState.value + viewModel.handlePrimaryButtonClick(screenState) + + val argumentCaptor = argumentCaptor() + + verify(mockCollectBankAccountLauncher).presentWithPaymentIntent( + publishableKey = any(), + stripeAccountId = anyOrNull(), + clientSecret = any(), + configuration = argumentCaptor.capture(), + ) + + val instantDebitsConfiguration = argumentCaptor.firstValue as CollectBankAccountConfiguration.InstantDebits + return instantDebitsConfiguration.elementsSessionContext + } + + private fun createArgsForBillingDetailsCollectionInInstantDebits( + collectEmail: Boolean, + collectName: Boolean, + collectPhone: Boolean, + collectAddress: Boolean, + attachDefaultsToPaymentMethod: Boolean, + ): USBankAccountFormViewModel.Args { + val billingDetails = PaymentSheet.BillingDetails( + name = CUSTOMER_NAME, + email = CUSTOMER_EMAIL, + phone = CUSTOMER_PHONE, + address = CUSTOMER_ADDRESS, + ) + + val billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + name = if (collectName) CollectionMode.Always else CollectionMode.Never, + email = if (collectEmail) CollectionMode.Always else CollectionMode.Never, + phone = if (collectPhone) CollectionMode.Always else CollectionMode.Never, + address = if (collectAddress) AddressCollectionMode.Full else AddressCollectionMode.Never, + attachDefaultsToPaymentMethod = attachDefaultsToPaymentMethod, + ) + + return defaultArgs.copy( + instantDebits = true, + formArgs = defaultArgs.formArgs.copy( + billingDetails = billingDetails, + billingDetailsCollectionConfiguration = billingDetailsCollectionConfiguration, + ) + ) + } + private fun testAllowRedisplay( showCheckbox: Boolean, shouldSave: Boolean, diff --git a/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt b/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt index 48be39b0399..0123fc67eec 100644 --- a/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt +++ b/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt @@ -7,6 +7,7 @@ import com.stripe.android.core.BuildConfig object FeatureFlags { // Add any feature flags here val nativeLinkEnabled = FeatureFlag() + val instantDebitsBillingDetails = FeatureFlag() } @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)