diff --git a/financial-connections/detekt-baseline.xml b/financial-connections/detekt-baseline.xml index 2ec88779b7f..d0dfef94ba7 100644 --- a/financial-connections/detekt-baseline.xml +++ b/financial-connections/detekt-baseline.xml @@ -31,6 +31,8 @@ SwallowedException:PollAttachPaymentAccount.kt$PollAttachPaymentAccount$e: StripeException SwallowedException:PollAuthorizationSessionAccounts.kt$PollAuthorizationSessionAccounts$e: StripeException SwallowedException:PostAuthorizationSession.kt$PostAuthorizationSession$e: StripeException + TooManyFunctions:FinancialConnectionsConsumerSessionRepository.kt$FinancialConnectionsConsumerSessionRepository + TooManyFunctions:FinancialConnectionsConsumerSessionRepository.kt$FinancialConnectionsConsumerSessionRepositoryImpl : FinancialConnectionsConsumerSessionRepository TooManyFunctions:FinancialConnectionsManifestRepository.kt$FinancialConnectionsManifestRepository TooManyFunctions:FinancialConnectionsManifestRepository.kt$FinancialConnectionsManifestRepositoryImpl : FinancialConnectionsManifestRepository TooManyFunctions:FinancialConnectionsSheetNativeViewModel.kt$FinancialConnectionsSheetNativeViewModel : FinancialConnectionsViewModelTopAppBarHost diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt index 1adbac0b4a8..dfcf0df5aa5 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt @@ -4,6 +4,7 @@ import com.stripe.android.core.Logger import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.domain.AttachConsumerToLinkAccountSession import com.stripe.android.financialconnections.domain.GetCachedAccounts import com.stripe.android.financialconnections.domain.GetOrFetchSync @@ -19,12 +20,14 @@ import com.stripe.android.financialconnections.navigation.Destination.Networking import com.stripe.android.financialconnections.navigation.Destination.Success import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository +import com.stripe.attestation.IntegrityRequestManager import javax.inject.Inject +import javax.inject.Named internal interface LinkSignupHandler { suspend fun performSignup( - state: NetworkingLinkSignupState, + state: NetworkingLinkSignupState ): Pane fun handleSignupFailure( @@ -37,8 +40,10 @@ internal interface LinkSignupHandler { internal class LinkSignupHandlerForInstantDebits @Inject constructor( private val consumerRepository: FinancialConnectionsConsumerSessionRepository, private val attachConsumerToLinkAccountSession: AttachConsumerToLinkAccountSession, + private val integrityRequestManager: IntegrityRequestManager, private val getOrFetchSync: GetOrFetchSync, private val navigationManager: NavigationManager, + @Named(APPLICATION_ID) private val applicationId: String, private val handleError: HandleError, ) : LinkSignupHandler { @@ -47,18 +52,30 @@ internal class LinkSignupHandlerForInstantDebits @Inject constructor( ): Pane { val phoneController = state.payload()!!.phoneController - val signup = consumerRepository.signUp( - email = state.validEmail!!, - phoneNumber = state.validPhone!!, - country = phoneController.getCountryCode(), - ) + val manifest = getOrFetchSync().manifest + val signup = if (manifest.appVerificationEnabled) { + val token = integrityRequestManager.requestToken().getOrThrow() + consumerRepository.mobileSignUp( + email = state.validEmail!!, + phoneNumber = state.validPhone!!, + country = phoneController.getCountryCode(), + verificationToken = token, + appId = applicationId + ) + } else { + consumerRepository.signUp( + email = state.validEmail!!, + phoneNumber = state.validPhone!!, + country = phoneController.getCountryCode(), + ) + } attachConsumerToLinkAccountSession( consumerSessionClientSecret = signup.consumerSession.clientSecret, ) - val manifest = getOrFetchSync(refetchCondition = Always).manifest - return manifest.nextPane + // Refresh manifest to get the next pane + return getOrFetchSync(refetchCondition = Always).manifest.nextPane } override fun navigateToVerification() { @@ -76,11 +93,14 @@ internal class LinkSignupHandlerForInstantDebits @Inject constructor( } internal class LinkSignupHandlerForNetworking @Inject constructor( + private val consumerRepository: FinancialConnectionsConsumerSessionRepository, private val getOrFetchSync: GetOrFetchSync, private val getCachedAccounts: GetCachedAccounts, + private val integrityRequestManager: IntegrityRequestManager, private val saveAccountToLink: SaveAccountToLink, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val navigationManager: NavigationManager, + @Named(APPLICATION_ID) private val applicationId: String, private val logger: Logger, ) : LinkSignupHandler { @@ -92,14 +112,36 @@ internal class LinkSignupHandlerForNetworking @Inject constructor( val manifest = getOrFetchSync().manifest val phoneController = state.payload()!!.phoneController require(state.valid) { "Form invalid! ${state.validEmail} ${state.validPhone}" } - saveAccountToLink.new( - country = phoneController.getCountryCode(), - email = state.validEmail!!, - phoneNumber = state.validPhone!!, - selectedAccounts = selectedAccounts, - shouldPollAccountNumbers = manifest.isDataFlow, - ) + if (manifest.appVerificationEnabled) { + // ** New signup flow on verified flows: 2 requests ** + // 1. Mobile signup endpoint providing email + phone number + // 2. Separately call SaveAccountToLink with the newly created account. + val token = integrityRequestManager.requestToken().getOrThrow() + val signup = consumerRepository.mobileSignUp( + email = state.validEmail!!, + phoneNumber = state.validPhone!!, + country = phoneController.getCountryCode(), + verificationToken = token, + appId = applicationId, + ) + saveAccountToLink.existing( + consumerSessionClientSecret = signup.consumerSession.clientSecret, + selectedAccounts = selectedAccounts, + shouldPollAccountNumbers = manifest.isDataFlow, + ) + } else { + // ** Legacy signup endpoint on unverified flows: 1 request ** + // SaveAccountToLink endpoint Signs up when providing email + phone number + // **and** saves accounts to Link in the same request. + saveAccountToLink.new( + country = phoneController.getCountryCode(), + email = state.validEmail!!, + phoneNumber = state.validPhone!!, + selectedAccounts = selectedAccounts, + shouldPollAccountNumbers = manifest.isDataFlow, + ) + } return Pane.SUCCESS } 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 2c39342c7db..9149dfb1a7d 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 @@ -2,6 +2,7 @@ package com.stripe.android.financialconnections.repository import com.stripe.android.core.Logger import com.stripe.android.core.frauddetection.FraudDetectionDataRepository +import com.stripe.android.core.networking.ApiRequest import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext import com.stripe.android.financialconnections.FinancialConnectionsSheet.ElementsSessionContext.BillingDetails import com.stripe.android.financialconnections.domain.IsLinkWithStripe @@ -18,6 +19,7 @@ import com.stripe.android.model.ConsumerSignUpConsentAction.EnteredPhoneNumberCl import com.stripe.android.model.CustomEmailType import com.stripe.android.model.EmailSource import com.stripe.android.model.SharePaymentDetails +import com.stripe.android.model.SignUpParams import com.stripe.android.model.UpdateAvailableIncentives import com.stripe.android.model.VerificationType import com.stripe.android.repository.ConsumersApiService @@ -48,6 +50,14 @@ internal interface FinancialConnectionsConsumerSessionRepository { country: String, ): ConsumerSessionSignup + suspend fun mobileSignUp( + email: String, + phoneNumber: String, + country: String, + verificationToken: String, + appId: String + ): ConsumerSessionSignup + suspend fun startConsumerVerification( consumerSessionClientSecret: String, connectionsMerchantName: String?, @@ -140,11 +150,40 @@ private class FinancialConnectionsConsumerSessionRepositoryImpl( } override suspend fun signUp( + email: String, + phoneNumber: String, + country: String + ): ConsumerSessionSignup = performSignUp( + email = email, + phoneNumber = phoneNumber, + country = country, + signupCall = consumersApiService::signUp + ) + + override suspend fun mobileSignUp( email: String, phoneNumber: String, country: String, + verificationToken: String, + appId: String + ): ConsumerSessionSignup = performSignUp( + email = email, + phoneNumber = phoneNumber, + country = country, + verificationToken = verificationToken, + appId = appId, + signupCall = consumersApiService::mobileSignUp + ) + + private suspend fun performSignUp( + email: String, + phoneNumber: String, + country: String, + verificationToken: String? = null, + appId: String? = null, + signupCall: suspend (SignUpParams, ApiRequest.Options) -> Result ): ConsumerSessionSignup = mutex.withLock { - consumersApiService.signUp( + val signUpParams = SignUpParams( email = email, phoneNumber = phoneNumber, country = country, @@ -153,12 +192,16 @@ private class FinancialConnectionsConsumerSessionRepositoryImpl( amount = elementsSessionContext?.amount, currency = elementsSessionContext?.currency, incentiveEligibilitySession = elementsSessionContext?.incentiveEligibilitySession, - requestOptions = provideApiRequestOptions(useConsumerPublishableKey = false), requestSurface = requestSurface, consentAction = EnteredPhoneNumberClickedSaveToLink, - ).onSuccess { signup -> - updateCachedConsumerSessionFromSignup(signup) - }.getOrThrow() + verificationToken = verificationToken, + appId = appId + ) + + // Make the API call using the given lambda function + signupCall(signUpParams, provideApiRequestOptions(useConsumerPublishableKey = false)) + .onSuccess { signup -> updateCachedConsumerSessionFromSignup(signup) } + .getOrThrow() } override suspend fun startConsumerVerification( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForInstantDebitsTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForInstantDebitsTest.kt new file mode 100644 index 00000000000..fba510aa9af --- /dev/null +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForInstantDebitsTest.kt @@ -0,0 +1,107 @@ +package com.stripe.android.financialconnections.features.networkinglinksignup + +import com.stripe.android.financialconnections.ApiKeyFixtures.consumerSessionSignup +import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest +import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse +import com.stripe.android.financialconnections.domain.AttachConsumerToLinkAccountSession +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.HandleError +import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.Payload +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.presentation.Async +import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository +import com.stripe.android.financialconnections.utils.TestIntegrityRequestManager +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals + +class LinkSignupHandlerForInstantDebitsTest { + + private lateinit var handler: LinkSignupHandlerForInstantDebits + private val consumerRepository = mock() + private val getOrFetchSync = mock() + private val attachConsumerToLinkAccountSession = mock() + private val integrityRequestManager = TestIntegrityRequestManager() + private val navigationManager = mock() + private val handleError = mock() + + @Before + fun setUp() { + handler = LinkSignupHandlerForInstantDebits( + consumerRepository, + attachConsumerToLinkAccountSession, + integrityRequestManager, + getOrFetchSync, + navigationManager, + "applicationId", + handleError + ) + } + + private val validPayload = Payload( + merchantName = "Mock Merchant", + emailController = mock(), + prefilledEmail = "test@stripe.com", + sessionId = "fcsess_1234", + appVerificationEnabled = false, + phoneController = mock { + whenever(it.getCountryCode()).thenReturn("US") + }, + isInstantDebits = true, + content = mock() + ) + + @Test + fun `performSignup should navigate to next pane on success`() = runTest { + val testState = NetworkingLinkSignupState( + validEmail = "test@example.com", + validPhone = "+123456789", + isInstantDebits = true, + payload = Async.Success(validPayload) + ) + + val expectedPane = Pane.INSTITUTION_PICKER + whenever(getOrFetchSync(anyOrNull(), anyOrNull())).thenReturn( + syncResponse( + sessionManifest().copy( + nextPane = expectedPane, + appVerificationEnabled = true + ) + ) + ) + whenever( + consumerRepository.mobileSignUp( + email = any(), + phoneNumber = any(), + country = any(), + verificationToken = any(), + appId = any() + ) + ).thenReturn(consumerSessionSignup()) + + val result = handler.performSignup(testState) + + verify(attachConsumerToLinkAccountSession).invoke(any()) + assertEquals(expectedPane, result) + } + + @Test + fun `handleSignupFailure should call handleError with correct parameters`() = runTest { + val error = RuntimeException("Test Error") + handler.handleSignupFailure(error) + + verify(handleError).invoke( + extraMessage = "Error creating a Link account", + error = error, + pane = Pane.LINK_LOGIN, + displayErrorScreen = true + ) + } +} diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt new file mode 100644 index 00000000000..df17b4d1cd8 --- /dev/null +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt @@ -0,0 +1,157 @@ +package com.stripe.android.financialconnections.features.networkinglinksignup + +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.ApiKeyFixtures.consumerSessionSignup +import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest +import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.domain.CachedPartnerAccount +import com.stripe.android.financialconnections.domain.GetCachedAccounts +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.SaveAccountToLink +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.presentation.Async +import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository +import com.stripe.android.financialconnections.utils.TestIntegrityRequestManager +import com.stripe.android.financialconnections.utils.TestNavigationManager +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals + +class LinkSignupHandlerForNetworkingTest { + + private lateinit var handler: LinkSignupHandlerForNetworking + private val consumerRepository = mock() + private val getOrFetchSync = mock() + private val getCachedAccounts = mock() + private val saveAccountToLink = mock() + private val integrityRequestManager = TestIntegrityRequestManager() + private val navigationManager = TestNavigationManager() + private val eventTracker = mock() + private val logger = mock() + + @Before + fun setUp() { + handler = LinkSignupHandlerForNetworking( + consumerRepository, + getOrFetchSync, + getCachedAccounts, + integrityRequestManager, + saveAccountToLink, + eventTracker, + navigationManager, + "applicationId", + logger + ) + } + + private val validPayload = NetworkingLinkSignupState.Payload( + merchantName = "Mock Merchant", + emailController = mock(), + prefilledEmail = "test@networking.com", + sessionId = "fcsess_5678", + appVerificationEnabled = true, + phoneController = mock { + whenever(it.getCountryCode()).thenReturn("US") + }, + isInstantDebits = false, + content = mock() + ) + + @Test + fun `performSignup on verified flows should save account and return success pane on success`() = runTest { + val testState = NetworkingLinkSignupState( + validEmail = "test@networking.com", + validPhone = "+1987654321", + isInstantDebits = false, + payload = Async.Success(validPayload) + ) + + val expectedPane = Pane.SUCCESS + + whenever(getOrFetchSync(anyOrNull(), anyOrNull())).thenReturn( + syncResponse(sessionManifest().copy(appVerificationEnabled = true)) + ) + whenever(getCachedAccounts()).thenReturn(listOf(mock())) // Mock a list of cached accounts + whenever( + consumerRepository.mobileSignUp( + email = any(), + phoneNumber = any(), + country = any(), + verificationToken = any(), + appId = any() + ) + ).thenReturn(consumerSessionSignup()) + + val result = handler.performSignup(testState) + + verify(consumerRepository).mobileSignUp( + email = eq("test@networking.com"), + phoneNumber = eq("+1987654321"), + country = eq("US"), + verificationToken = eq(integrityRequestManager.requestTokenResult.getOrThrow()), + appId = eq("applicationId") + ) + verify(saveAccountToLink).existing(any(), any(), any()) + assertEquals(expectedPane, result) + } + + @Test + fun `handleSignupFailure should log error and navigate to Success pane`() = runTest { + val error = RuntimeException("Test Error") + + handler.handleSignupFailure(error) + + verify(eventTracker).logError( + extraMessage = "Error saving account to Link", + error = error, + logger = logger, + pane = Pane.NETWORKING_LINK_SIGNUP_PANE + ) + + navigationManager.assertNavigatedTo( + destination = Destination.Success, + pane = Pane.NETWORKING_LINK_SIGNUP_PANE + ) + } + + @Test + fun `performSignup should use legacy signup flow when verification is false`() = runTest { + val testState = NetworkingLinkSignupState( + validEmail = "legacy@example.com", + validPhone = "+1987654321", + isInstantDebits = false, + payload = Async.Success(validPayload) + ) + + whenever(getOrFetchSync(anyOrNull(), anyOrNull())).thenReturn( + syncResponse(sessionManifest().copy(appVerificationEnabled = false)) + ) + val cachedAccounts = listOf(mock()) // Ensure this matches what you return + whenever(getCachedAccounts()).thenReturn(cachedAccounts) + + val expectedPane = Pane.SUCCESS + + val result = handler.performSignup(testState) + + verifyNoInteractions(consumerRepository) + verify(saveAccountToLink).new( + email = eq("legacy@example.com"), + phoneNumber = eq("+1987654321"), + selectedAccounts = eq(cachedAccounts), + country = eq("US"), + shouldPollAccountNumbers = eq(true) + ) + assertEquals(expectedPane, result) + } +} diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt index 9911116f1dd..1cd3d15dd8a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/NetworkingLinkSignupViewModelTest.kt @@ -619,10 +619,13 @@ class NetworkingLinkSignupViewModelTest { return LinkSignupHandlerForNetworking( getOrFetchSync = getOrFetchSync, + consumerRepository = consumerSessionRepository(failOnSignup = false), getCachedAccounts = getCachedAccounts, saveAccountToLink = saveAccountToLink, eventTracker = eventTracker, navigationManager = navigationManager, + integrityRequestManager = mock(), + applicationId = "test", logger = Logger.noop(), ) } @@ -646,15 +649,7 @@ class NetworkingLinkSignupViewModelTest { ) } - val consumerRepository = mock { - if (failOnSignup) { - onBlocking { signUp(any(), any(), any()) } doAnswer { - throw APIConnectionException() - } - } else { - onBlocking { signUp(any(), any(), any()) } doReturn consumerSessionSignup() - } - } + val consumerRepository = consumerSessionRepository(failOnSignup) return LinkSignupHandlerForInstantDebits( getOrFetchSync = getOrFetchSync, @@ -664,9 +659,28 @@ class NetworkingLinkSignupViewModelTest { }, navigationManager = navigationManager, handleError = handleError, + integrityRequestManager = mock(), + applicationId = "test", ) } + private fun consumerSessionRepository(failOnSignup: Boolean): FinancialConnectionsConsumerSessionRepository { + val consumerRepository = mock { + if (failOnSignup) { + onBlocking { signUp(any(), any(), any()) } doAnswer { + throw APIConnectionException() + } + onBlocking { mobileSignUp(any(), any(), any(), any(), any()) } doAnswer { + throw APIConnectionException() + } + } else { + onBlocking { signUp(any(), any(), any()) } doReturn consumerSessionSignup() + onBlocking { mobileSignUp(any(), any(), any(), any(), any()) } doReturn consumerSessionSignup() + } + } + return consumerRepository + } + private fun networkingLinkSignupPane() = NetworkingLinkSignupPane( aboveCta = "Above CTA", body = NetworkingLinkSignupBody(emptyList()), 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 710d9d8c6c6..8abc319b4f7 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 @@ -26,7 +26,9 @@ import com.stripe.android.repository.ConsumersApiService import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -81,17 +83,8 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { whenever( consumersApiService.signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - consentAction = anyOrNull(), - requestSurface = anyOrNull(), - requestOptions = anyOrNull(), + params = any(), + requestOptions = anyOrNull() ) ).thenReturn(Result.success(consumerSessionSignup)) @@ -138,16 +131,10 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { whenever( consumersApiService.signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = eq(1234), - currency = eq("cad"), - incentiveEligibilitySession = eq(IncentiveEligibilitySession.PaymentIntent("pi_123")), - consentAction = anyOrNull(), - requestSurface = anyOrNull(), + params = argThat { + currency == "cad" && + incentiveEligibilitySession == IncentiveEligibilitySession.PaymentIntent("pi_123") + }, requestOptions = anyOrNull(), ) ).thenReturn( @@ -331,17 +318,8 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { whenever( consumersApiService.signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - consentAction = anyOrNull(), - requestSurface = anyOrNull(), - requestOptions = anyOrNull(), + params = any(), + requestOptions = anyOrNull() ) ).thenReturn(Result.success(consumerSessionSignup())) @@ -352,17 +330,8 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { ) verify(consumersApiService).signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - requestSurface = eq("android_connections"), - consentAction = anyOrNull(), - requestOptions = anyOrNull(), + params = argThat { requestSurface == "android_connections" }, + requestOptions = anyOrNull() ) } @@ -372,17 +341,8 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { whenever( consumersApiService.signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - consentAction = anyOrNull(), - requestSurface = anyOrNull(), - requestOptions = anyOrNull(), + params = any(), + requestOptions = anyOrNull() ) ).thenReturn(Result.success(consumerSessionSignup())) @@ -393,17 +353,10 @@ class FinancialConnectionsConsumerSessionRepositoryImplTest { ) verify(consumersApiService).signUp( - email = anyOrNull(), - phoneNumber = anyOrNull(), - country = anyOrNull(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - requestSurface = eq("android_instant_debits"), - consentAction = anyOrNull(), - requestOptions = anyOrNull(), + params = argThat { + requestSurface == "android_instant_debits" + }, + requestOptions = anyOrNull() ) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestIntegrityRequestManager.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestIntegrityRequestManager.kt index a922a9f9e04..c5604896fd2 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestIntegrityRequestManager.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/utils/TestIntegrityRequestManager.kt @@ -6,6 +6,7 @@ internal class TestIntegrityRequestManager( val prepareResult: Result = Result.success(Unit), val requestTokenResult: Result = Result.success("token") ) : IntegrityRequestManager { + override suspend fun prepare(): Result = prepareResult override suspend fun requestToken(requestIdentifier: String?): Result = requestTokenResult diff --git a/payments-model/src/main/java/com/stripe/android/model/SignUpParams.kt b/payments-model/src/main/java/com/stripe/android/model/SignUpParams.kt new file mode 100644 index 00000000000..6ba65987d20 --- /dev/null +++ b/payments-model/src/main/java/com/stripe/android/model/SignUpParams.kt @@ -0,0 +1,53 @@ +package com.stripe.android.model + +import androidx.annotation.RestrictTo +import java.util.Locale + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +data class SignUpParams( + val email: String, + val phoneNumber: String, + val country: String, + val name: String?, + val locale: Locale?, + val amount: Long?, + val currency: String?, + val incentiveEligibilitySession: IncentiveEligibilitySession?, + val requestSurface: String, + val consentAction: ConsumerSignUpConsentAction, + val verificationToken: String? = null, + val appId: String? = null +) { + fun toParamMap(): Map { + val params = mutableMapOf( + "email_address" to email.lowercase(), + "phone_number" to phoneNumber, + "country" to country, + "country_inferring_method" to "PHONE_NUMBER", + "amount" to amount, + "currency" to currency, + "consent_action" to consentAction.value, + "request_surface" to requestSurface + ) + + locale?.let { + params["locale"] = it.toLanguageTag() + } + + name?.let { + params["legal_name"] = it + } + + verificationToken?.let { + params["android_verification_token"] = it + } + + appId?.let { + params["app_id"] = it + } + + params.putAll(incentiveEligibilitySession?.toParamMap().orEmpty()) + + return params.toMap() + } +} 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 f8d1825ad16..d1ad4eeb6e4 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 @@ -14,11 +14,10 @@ import com.stripe.android.model.ConsumerPaymentDetailsCreateParams import com.stripe.android.model.ConsumerSession import com.stripe.android.model.ConsumerSessionLookup import com.stripe.android.model.ConsumerSessionSignup -import com.stripe.android.model.ConsumerSignUpConsentAction import com.stripe.android.model.CustomEmailType import com.stripe.android.model.EmailSource -import com.stripe.android.model.IncentiveEligibilitySession import com.stripe.android.model.SharePaymentDetails +import com.stripe.android.model.SignUpParams import com.stripe.android.model.UpdateAvailableIncentives import com.stripe.android.model.VerificationType import com.stripe.android.model.parsers.AttachConsumerToLinkAccountSessionJsonParser @@ -34,19 +33,15 @@ import java.util.Locale interface ConsumersApiService { suspend fun signUp( - email: String, - phoneNumber: String, - country: String, - name: String?, - locale: Locale?, - amount: Long?, - currency: String?, - incentiveEligibilitySession: IncentiveEligibilitySession?, - requestSurface: String, - consentAction: ConsumerSignUpConsentAction, + params: SignUpParams, requestOptions: ApiRequest.Options, ): Result + suspend fun mobileSignUp( + params: SignUpParams, + requestOptions: ApiRequest.Options + ): Result + suspend fun lookupConsumerSession( email: String, requestSurface: String, @@ -131,16 +126,7 @@ class ConsumersApiServiceImpl( ) override suspend fun signUp( - email: String, - phoneNumber: String, - country: String, - name: String?, - locale: Locale?, - amount: Long?, - currency: String?, - incentiveEligibilitySession: IncentiveEligibilitySession?, - requestSurface: String, - consentAction: ConsumerSignUpConsentAction, + params: SignUpParams, requestOptions: ApiRequest.Options, ): Result { return executeRequestWithResultParser( @@ -149,26 +135,26 @@ class ConsumersApiServiceImpl( request = apiRequestFactory.createPost( url = consumerAccountsSignUpUrl, options = requestOptions, - params = mapOf( - "email_address" to email.lowercase(), - "phone_number" to phoneNumber, - "country" to country, - "country_inferring_method" to "PHONE_NUMBER", - "amount" to amount, - "currency" to currency, - "consent_action" to consentAction.value, - "request_surface" to requestSurface, - ).plus( - locale?.let { - mapOf("locale" to it.toLanguageTag()) - } ?: emptyMap() - ).plus( - name?.let { - mapOf("legal_name" to it) - } ?: emptyMap() - ).plus( - incentiveEligibilitySession?.toParamMap().orEmpty() - ), + params = params.toParamMap() + ), + responseJsonParser = ConsumerSessionSignupJsonParser, + ) + } + + /** + * Retrieves the ConsumerSession if the given email is associated with a Link account. + */ + override suspend fun mobileSignUp( + params: SignUpParams, + requestOptions: ApiRequest.Options + ): Result { + return executeRequestWithResultParser( + stripeErrorJsonParser = stripeErrorJsonParser, + stripeNetworkClient = stripeNetworkClient, + request = apiRequestFactory.createPost( + url = consumerMobileSignUpUrl, + options = requestOptions, + params = params.toParamMap() ), responseJsonParser = ConsumerSessionSignupJsonParser, ) @@ -400,6 +386,12 @@ class ConsumersApiServiceImpl( internal val consumerAccountsSignUpUrl: String = getApiUrl("consumers/accounts/sign_up") + /** + * @return `https://api.stripe.com/v1/consumers/mobile/sign_up` + */ + internal val consumerMobileSignUpUrl: String = + getApiUrl("consumers/mobile/sign_up") + /** * @return `https://api.stripe.com/v1/consumers/sessions/lookup` */ diff --git a/payments-model/src/test/java/com/stripe/android/repository/ConsumersApiServiceImplTest.kt b/payments-model/src/test/java/com/stripe/android/repository/ConsumersApiServiceImplTest.kt index 2125799ba47..a79761ad737 100644 --- a/payments-model/src/test/java/com/stripe/android/repository/ConsumersApiServiceImplTest.kt +++ b/payments-model/src/test/java/com/stripe/android/repository/ConsumersApiServiceImplTest.kt @@ -13,6 +13,7 @@ import com.stripe.android.model.ConsumerPaymentDetailsCreateParams import com.stripe.android.model.ConsumerSession import com.stripe.android.model.ConsumerSignUpConsentAction import com.stripe.android.model.IncentiveEligibilitySession +import com.stripe.android.model.SignUpParams import com.stripe.android.model.VerificationType import com.stripe.android.networktesting.NetworkRule import com.stripe.android.networktesting.RequestMatchers.bodyPart @@ -61,16 +62,18 @@ class ConsumersApiServiceImplTest { } val signup = consumersApiService.signUp( - email = email, - phoneNumber = "+15555555568", - country = "US", - name = null, - locale = Locale.US, - amount = 1234, - currency = "cad", - incentiveEligibilitySession = IncentiveEligibilitySession.PaymentIntent("pi_123"), - consentAction = ConsumerSignUpConsentAction.Checkbox, - requestSurface = requestSurface, + SignUpParams( + email = email, + phoneNumber = "+15555555568", + country = "US", + name = null, + locale = Locale.US, + amount = 1234, + currency = "cad", + incentiveEligibilitySession = IncentiveEligibilitySession.PaymentIntent("pi_123"), + consentAction = ConsumerSignUpConsentAction.Checkbox, + requestSurface = requestSurface, + ), requestOptions = DEFAULT_OPTIONS, ).getOrThrow() diff --git a/paymentsheet/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt b/paymentsheet/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt index 5487bd06286..481c6aea344 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt @@ -15,6 +15,7 @@ import com.stripe.android.model.ConsumerSessionLookup import com.stripe.android.model.ConsumerSessionSignup import com.stripe.android.model.ConsumerSignUpConsentAction import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.SignUpParams import com.stripe.android.model.StripeIntent import com.stripe.android.model.VerificationType import com.stripe.android.networking.StripeRepository @@ -61,17 +62,19 @@ internal class LinkApiRepository @Inject constructor( consentAction: ConsumerSignUpConsentAction ): Result = withContext(workContext) { consumersApiService.signUp( - email = email, - phoneNumber = phone, - country = country, - name = name, - locale = locale, - amount = null, - currency = null, - incentiveEligibilitySession = null, - consentAction = consentAction, + SignUpParams( + email = email, + phoneNumber = phone, + country = country, + name = name, + locale = locale, + amount = null, + currency = null, + incentiveEligibilitySession = null, + consentAction = consentAction, + requestSurface = REQUEST_SURFACE + ), requestOptions = buildRequestOptions(), - requestSurface = REQUEST_SURFACE, ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt index 36870455e88..a58721a0676 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt @@ -14,6 +14,7 @@ import com.stripe.android.model.ConsumerSessionSignup import com.stripe.android.model.ConsumerSignUpConsentAction import com.stripe.android.model.PaymentIntent import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.SignUpParams import com.stripe.android.model.VerificationType import com.stripe.android.networking.StripeRepository import com.stripe.android.payments.core.analytics.ErrorReporter @@ -124,16 +125,18 @@ class LinkApiRepositoryTest { ) verify(consumersApiService).signUp( - email = email, - phoneNumber = phone, - country = country, - name = name, - locale = Locale.US, - amount = null, - currency = null, - incentiveEligibilitySession = null, - requestSurface = "android_payment_element", - consentAction = ConsumerSignUpConsentAction.Checkbox, + SignUpParams( + email = email, + phoneNumber = phone, + country = country, + name = name, + locale = Locale.US, + amount = null, + currency = null, + incentiveEligibilitySession = null, + requestSurface = "android_payment_element", + consentAction = ConsumerSignUpConsentAction.Checkbox, + ), requestOptions = ApiRequest.Options(PUBLISHABLE_KEY, STRIPE_ACCOUNT_ID), ) } @@ -143,16 +146,7 @@ class LinkApiRepositoryTest { val consumerSession = mock() whenever( consumersApiService.signUp( - email = any(), - phoneNumber = any(), - country = any(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - requestSurface = any(), - consentAction = any(), + params = any(), requestOptions = any() ) ).thenReturn(Result.success(consumerSession)) @@ -173,16 +167,7 @@ class LinkApiRepositoryTest { fun `consumerSignUp catches exception and returns failure`() = runTest { whenever( consumersApiService.signUp( - email = any(), - phoneNumber = any(), - country = any(), - name = anyOrNull(), - locale = anyOrNull(), - amount = anyOrNull(), - currency = anyOrNull(), - incentiveEligibilitySession = anyOrNull(), - requestSurface = any(), - consentAction = any(), + params = any(), requestOptions = any() ) ).thenReturn(Result.failure(RuntimeException("error")))