From 83dc18358eab9229e2492f45a45048ac8ef5d1fc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 14:58:19 -0400 Subject: [PATCH 1/2] feat(phone): update protos and scaffold LinkForPayment RPC Update flipcash protobuf definitions and add service layer stubs for the new LinkForPayment RPC which links a verified phone number for payment. Signed-off-by: Brandon McAnsh --- .../phone/v1/phone_verification_service.proto | 20 +++++++++++++ .../ContactVerificationController.kt | 7 +++++ .../network/api/PhoneVerificationApi.kt | 16 ++++++++++ .../services/PhoneVerificationService.kt | 29 +++++++++++++++++++ .../InternalContactVerificationRepository.kt | 5 ++++ .../com/flipcash/services/models/Errors.kt | 10 +++++++ .../ContactVerificationRepository.kt | 2 +- 7 files changed, 88 insertions(+), 1 deletion(-) diff --git a/definitions/flipcash/protos/src/main/proto/phone/v1/phone_verification_service.proto b/definitions/flipcash/protos/src/main/proto/phone/v1/phone_verification_service.proto index a4a50c445..5229d6989 100644 --- a/definitions/flipcash/protos/src/main/proto/phone/v1/phone_verification_service.proto +++ b/definitions/flipcash/protos/src/main/proto/phone/v1/phone_verification_service.proto @@ -22,6 +22,9 @@ service PhoneVerification { // Unlink removes the link of a phone number from a user. rpc Unlink(UnlinkRequest) returns (UnlinkResponse); + + // LinkForPayment links the verified phone number for the requesting user for payment. + rpc LinkForPayment(LinkForPaymentRequest) returns (LinkForPaymentResponse); } message SendVerificationCodeRequest { @@ -94,3 +97,20 @@ message UnlinkResponse { DENIED = 1; } } + + +message LinkForPaymentRequest { + // The phone number to link for payment + PhoneNumber phone_number = 1 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 2 [(validate.rules).message.required = true]; +} + +message LinkForPaymentResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_ASSOCIATED = 2; + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt index 266e63588..aad2d3cb5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ContactVerificationController.kt @@ -36,4 +36,11 @@ class ContactVerificationController @Inject constructor( userManager.set(updated) } } + + suspend fun linkForPayment(method: ContactMethod.Phone): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.linkForPayment(method, owner) + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt index 606d3e4fc..27bfd7642 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt @@ -87,4 +87,20 @@ internal class PhoneVerificationApi @Inject constructor( api.unlink(request) } } + + suspend fun linkForPayment( + request: ContactMethod.Phone, + owner: Ed25519.KeyPair + ): PhoneVerificationService.LinkForPaymentResponse { + val request = PhoneVerificationService.LinkForPaymentRequest.newBuilder() + .setPhoneNumber(Model.PhoneNumber.newBuilder().setValue(request.phoneNumber).build()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.linkForPayment(request) + } + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PhoneVerificationService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PhoneVerificationService.kt index 33a1a8c6a..1f9175433 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PhoneVerificationService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PhoneVerificationService.kt @@ -3,6 +3,7 @@ package com.flipcash.services.internal.network.services import com.flipcash.services.internal.network.api.PhoneVerificationApi import com.getcode.opencode.utils.toValidationOrElse import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.LinkForPaymentError import com.flipcash.services.models.PhoneVerificationError import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.extensions.foldWithSuppression @@ -111,4 +112,32 @@ internal class PhoneVerificationService @Inject constructor( } ) } + + suspend fun linkForPayment( + request: ContactMethod.Phone, + owner: Ed25519.KeyPair + ): Result { + return runCatching { + api.linkForPayment(request, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcPhoneService.LinkForPaymentResponse.Result.OK -> Result.success(Unit) + RpcPhoneService.LinkForPaymentResponse.Result.DENIED -> { + Result.failure(LinkForPaymentError.Denied()) + } + RpcPhoneService.LinkForPaymentResponse.Result.NOT_ASSOCIATED -> { + Result.failure(LinkForPaymentError.NotAssociated()) + } + RpcPhoneService.LinkForPaymentResponse.Result.UNRECOGNIZED -> { + Result.failure(LinkForPaymentError.Unrecognized()) + } + else -> Result.failure(LinkForPaymentError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { LinkForPaymentError.Other(it) }) + } + ) + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactVerificationRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactVerificationRepository.kt index fd2cc827d..a39028866 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactVerificationRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactVerificationRepository.kt @@ -38,4 +38,9 @@ internal class InternalContactVerificationRepository( is ContactMethod.Phone -> phoneService.unlink(method, owner) }.onFailure { ErrorUtils.handleError(it) } } + + override suspend fun linkForPayment(method: ContactMethod.Phone, owner: Ed25519.KeyPair): Result { + return phoneService.linkForPayment(method, owner) + .onFailure { ErrorUtils.handleError(it) } + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index ba4455a57..0daa6d9da 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -175,6 +175,16 @@ sealed class PhoneVerificationError( data class Other(override val cause: Throwable? = null) : PhoneVerificationError(message = cause?.message, cause = cause), NotifiableError } +sealed class LinkForPaymentError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: LinkForPaymentError("Denied") + class NotAssociated: LinkForPaymentError("Not associated") + class Unrecognized : LinkForPaymentError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : LinkForPaymentError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetUserProfileError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactVerificationRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactVerificationRepository.kt index add476dba..54dbf6c33 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactVerificationRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ContactVerificationRepository.kt @@ -7,5 +7,5 @@ interface ContactVerificationRepository { suspend fun sendVerificationCode(method: ContactMethod, owner: Ed25519.KeyPair): Result suspend fun checkVerificationCode(method: ContactMethod, code: String, owner: Ed25519.KeyPair): Result suspend fun unlink(method: ContactMethod, owner: Ed25519.KeyPair): Result - + suspend fun linkForPayment(method: ContactMethod.Phone, owner: Ed25519.KeyPair): Result } \ No newline at end of file From 1e0c20e28b4ad95952c645b501e8294e4bc80dcb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 28 May 2026 15:43:21 -0400 Subject: [PATCH 2/2] feat(phone): dispatch LinkForPayment on verification and onboarding complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the LinkForPayment RPC so it fires in two cases: - After phone verification from the send flow (via linkForPayment flag on Verification route) - After onboarding completes (via OnboardingViewModel) Also fix a bug where phone verification was shown during onboarding even when the phone-number-send feature flag was disabled — resolvePostAccountRoute now checks phoneNumberSendEnabled before routing to verification. Signed-off-by: Brandon McAnsh --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 1 + .../contact-verification/build.gradle.kts | 1 + .../verification/VerificationFlowScreen.kt | 6 +- .../phone/PhoneVerificationViewModel.kt | 23 +++++++ .../verification/phone/PhoneCodeScreen.kt | 6 +- .../PhoneVerificationViewModelErrorTest.kt | 6 ++ .../app/login/OnboardingFlowScreen.kt | 38 ++++------- .../app/login/internal/OnboardingViewModel.kt | 30 +++++++++ .../app/login/OnboardingRoutingTest.kt | 65 +++---------------- 9 files changed, 92 insertions(+), 84 deletions(-) create mode 100644 apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 778f8d278..df3367e5a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -119,6 +119,7 @@ sealed interface AppRoute : NavKey, Parcelable { val emailVerificationCode: String? = null, val target: AppRoute? = null, val fullScreen: Boolean = false, + val linkForPayment: Boolean = false, ) : AppRoute, FlowRouteWithResult { override val initialStack: List get() = buildVerificationInitialStack( diff --git a/apps/flipcash/features/contact-verification/build.gradle.kts b/apps/flipcash/features/contact-verification/build.gradle.kts index 63d4ed7c5..0dc3410de 100644 --- a/apps/flipcash/features/contact-verification/build.gradle.kts +++ b/apps/flipcash/features/contact-verification/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:phone")) implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt index 3ffd8fb11..78bd39e4f 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt @@ -66,7 +66,11 @@ private fun verificationEntryProvider( PhoneVerificationContent(isInModal = !route.fullScreen) } annotatedEntry { - PhoneCodeContent(includeEmail = route.includeEmail, isInModal = !route.fullScreen) + PhoneCodeContent( + includeEmail = route.includeEmail, + isInModal = !route.fullScreen, + linkForPayment = route.linkForPayment, + ) } annotatedEntry { PhoneCountryCodeContent(isInModal = !route.fullScreen) diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index b01ada715..215b7a498 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.CountryLocale import com.flipcash.app.phone.PhoneUtils import com.flipcash.features.contact.verification.R @@ -11,6 +13,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.ContactMethod +import com.flipcash.services.user.UserManager import com.flipcash.services.models.PhoneVerificationError import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -19,6 +22,7 @@ import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn @@ -37,6 +41,8 @@ internal class PhoneVerificationViewModel @Inject constructor( private val phoneUtils: PhoneUtils, private val verificationController: ContactVerificationController, private val profileController: ProfileController, + private val userManager: UserManager, + private val featureFlags: FeatureFlagController, private val resources: ResourceHelper, private val dispatchers: DispatcherProvider, ) : BaseViewModel( @@ -88,6 +94,7 @@ internal class PhoneVerificationViewModel @Inject constructor( data object OnVerifyCodeClicked : Event data object OnCodeVerified : Event + data object LinkForPayment : Event data object OnMaxAttemptsReached : Event } @@ -196,6 +203,21 @@ internal class PhoneVerificationViewModel @Inject constructor( ) } ).launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .filter { + featureFlags.observe(FeatureFlag.PhoneNumberSend).value || + userManager.state.value.flags?.enablePhoneNumberSend == true + } + .map { + val number = stateFlow.value.numberTextFieldState.text.toString() + val locale = stateFlow.value.selectedLocale + val cleanedNumber = phoneUtils.cleanNumber(number, locale) + ContactMethod.Phone(cleanedNumber) + } + .map { verificationController.linkForPayment(it) } + .launchIn(viewModelScope) } private suspend fun handleSendVerificationCode(method: ContactMethod) { @@ -296,6 +318,7 @@ internal class PhoneVerificationViewModel @Inject constructor( } Event.OnVerifyCodeClicked -> { state -> state } Event.OnCodeVerified -> { state -> state } + Event.LinkForPayment -> { state -> state } is Event.OnPhoneNumberFormatted -> { state -> state.copy(formattedPhone = event.formatted) } Event.OnSendCodeClicked -> { state -> state.copy(attempts = state.attempts + 1) } Event.OnMaxAttemptsReached -> { state -> state } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt index 0888c6847..8aa523933 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.onEach fun PhoneCodeContent( includeEmail: Boolean, isInModal: Boolean = true, + linkForPayment: Boolean = false, ) { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() @@ -55,10 +56,13 @@ fun PhoneCodeContent( .launchIn(this) } - LaunchedEffect(viewModel, includeEmail) { + LaunchedEffect(viewModel, includeEmail, linkForPayment) { viewModel.eventFlow .filterIsInstance() .onEach { + if (linkForPayment) { + viewModel.dispatchEvent(PhoneVerificationViewModel.Event.LinkForPayment) + } if (includeEmail) { flowNavigator.navigateTo(VerificationStep.EmailEntry) } else { diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt index 78f173bb5..b994678ce 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelErrorTest.kt @@ -1,10 +1,12 @@ package com.flipcash.app.contact.verification.internal.phone +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.PhoneUtils import com.flipcash.features.contact.verification.R import com.flipcash.services.controllers.ContactVerificationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.PhoneVerificationError +import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.flipcash.app.core.MainCoroutineRule @@ -34,6 +36,8 @@ class PhoneVerificationViewModelErrorTest { // Mockito for Result-returning methods (MockK double-boxes Result inline class) private val verificationController: ContactVerificationController = mock() private val profileController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val featureFlags = mockk(relaxed = true) private val resources = mockk(relaxed = true) private lateinit var dispatchers: TestDispatchers @@ -62,6 +66,8 @@ class PhoneVerificationViewModelErrorTest { phoneUtils = phoneUtils, verificationController = verificationController, profileController = profileController, + userManager = userManager, + featureFlags = featureFlags, resources = resources, dispatchers = dispatchers, ) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index d679293a2..99e86b295 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -33,6 +33,7 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.core.onboarding.OnboardingResult import com.flipcash.app.core.onboarding.OnboardingStep import com.flipcash.app.login.internal.LoginAccessKeyViewModel +import com.flipcash.app.login.internal.OnboardingViewModel import com.flipcash.app.login.internal.screens.PhotoAccessKeyScreen import com.flipcash.app.login.internal.screens.AccessKeyScreen import com.flipcash.app.login.internal.screens.LoginRouterScreenContent @@ -73,13 +74,13 @@ import kotlinx.coroutines.flow.onEach * ``` * 1. New account (ResumePoint.Login → ProceedToVerification) * - * Start → Verification³ → AccessKey ──┬──→ Contacts¹ → Notifications → Scanner + * Start → Verification² → AccessKey ──┬──────────────→ Contacts¹ → Notifications → Scanner * └→ Purchase ─┘ * * 2. Seed restore (ResumePoint.Login → LoggedIn via SeedInput) * - * Start → SeedInput ──┬──────────────────────→ Contacts¹ → Notifications → Scanner - * └→ Purchase → Verification² ─┘ + * Start → SeedInput ──┬──────────────→ Contacts¹ → Notifications → Scanner + * └→ Purchase ─┘ * * 3. App resume (ResumePoint.PostAccessKey) * @@ -95,9 +96,9 @@ import kotlinx.coroutines.flow.onEach * **and** [FeatureFlag.ContactPickerMode] is off. When ContactPickerMode is on, * contacts are accessed via the system picker at call site (no READ_CONTACTS needed). * Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. - * ² Verification is skipped if a phone number is already linked. - * ³ Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled - * and no phone is linked. Uses `target` to replace nav stack with AccessKey on success. + * ² Phone verification is shown only when [FeatureFlag.PhoneNumberSend] is enabled + * and no phone is linked. Skipped entirely when the flag is off. + * Uses `target` to replace the nav stack with AccessKey on success. */ @Composable fun OnboardingFlowScreen( @@ -134,6 +135,7 @@ private fun PermissionsPhaseFlowHost( resultStateRegistry: NavResultStateRegistry, ) { val outerNavigator = LocalCodeNavigator.current + val onboardingViewModel = hiltViewModel() val checker = LocalPermissionChecker.current val contactConfig = PermissionConfigs.contacts() val notificationConfig = PermissionConfigs.notifications() @@ -178,6 +180,7 @@ private fun PermissionsPhaseFlowHost( when (reason) { is FlowExitReason.Completed -> { analytics.action(Action.CompletedOnboarding) + onboardingViewModel.linkPhoneForPayment() outerNavigator.navigate( route = AppRoute.Main.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), @@ -186,6 +189,7 @@ private fun PermissionsPhaseFlowHost( FlowExitReason.BackedOutOfRoot -> { // All permissions already granted + onboardingViewModel.linkPhoneForPayment() outerNavigator.navigate( route = AppRoute.Main.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll), @@ -205,19 +209,16 @@ private fun AccountPhaseFlowHost( resultStateRegistry: NavResultStateRegistry, ) { val outerNavigator = LocalCodeNavigator.current - val userManager = LocalUserManager.current!! - val userState by userManager.state.collectAsStateWithLifecycle() val initialStack = route.rememberInitialStack() - FlowHost( + FlowHost( initialStack = initialStack, resultStateRegistry = resultStateRegistry, onExit = { reason, _ -> when (reason) { is FlowExitReason.Completed -> { - val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null - val route = resolvePostAccountRoute(reason.result, hasLinkedPhone) + val route = resolvePostAccountRoute(reason.result) route?.let { outerNavigator.replace(it) } } @@ -231,7 +232,6 @@ private fun AccountPhaseFlowHost( internal fun resolvePostAccountRoute( result: OnboardingResult, - hasLinkedPhone: Boolean, skipContacts: Boolean = false, ): AppRoute? { val permissionsRoute = AppRoute.OnboardingFlow( @@ -239,19 +239,7 @@ internal fun resolvePostAccountRoute( skipContacts = skipContacts, ) return when (result) { - is OnboardingResult.ProceedToVerification -> { - if (hasLinkedPhone) { - permissionsRoute - } else { - AppRoute.Verification( - origin = AppRoute.Onboarding.AccessKey, - includePhone = true, - includeEmail = false, - target = permissionsRoute, - fullScreen = true, - ) - } - } + is OnboardingResult.ProceedToVerification -> permissionsRoute OnboardingResult.LoggedIn -> permissionsRoute OnboardingResult.Completed -> null } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt new file mode 100644 index 000000000..793b517b7 --- /dev/null +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/OnboardingViewModel.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.login.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.services.controllers.ContactVerificationController +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.user.UserManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class OnboardingViewModel @Inject constructor( + private val userManager: UserManager, + private val featureFlags: FeatureFlagController, + private val contactVerificationController: ContactVerificationController, +) : ViewModel() { + + fun linkPhoneForPayment() { + val phone = userManager.profile?.verifiedPhoneNumber ?: return + val enabled = featureFlags.observe(FeatureFlag.PhoneNumberSend).value || + userManager.state.value.flags?.enablePhoneNumberSend == true + if (!enabled) return + viewModelScope.launch { + contactVerificationController.linkForPayment(ContactMethod.Phone(phone)) + } + } +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt index b6baec311..4ab36e891 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt @@ -9,72 +9,36 @@ import kotlin.test.assertNull class OnboardingRoutingTest { - // -- Path 1: New account (AccessKey exits with ProceedToVerification) -- + // -- Path 1 & 2: ProceedToVerification always routes to permissions -- @Test - fun `path 1 - new account routes through verification when phone not linked`() { + fun `ProceedToVerification routes to permissions phase`() { val route = resolvePostAccountRoute( result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = false, - ) - val verification = assertIs(route) - val target = assertIs(verification.target) - assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) - assertEquals(false, target.skipContacts) - } - - @Test - fun `path 1 - new account skips verification when phone already linked`() { - val route = resolvePostAccountRoute( - result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = true, ) val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(false, flow.skipContacts) } - // -- Path 2: Seed restore (SeedInput exits with LoggedIn, Purchase exits with ProceedToVerification) -- + // -- LoggedIn routes to permissions -- @Test - fun `path 2 - seed restore without IAP skips verification`() { + fun `LoggedIn routes to permissions phase`() { val route = resolvePostAccountRoute( result = OnboardingResult.LoggedIn, - hasLinkedPhone = false, ) val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(false, flow.skipContacts) } - @Test - fun `path 2 - seed restore with IAP routes through verification when phone not linked`() { - val route = resolvePostAccountRoute( - result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = false, - ) - val verification = assertIs(route) - val target = assertIs(verification.target) - assertEquals(false, target.skipContacts) - } + // -- skipContacts is forwarded -- @Test - fun `path 2 - seed restore with IAP skips verification when phone linked`() { + fun `skipContacts is forwarded to permissions route`() { val route = resolvePostAccountRoute( result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = true, - ) - val flow = assertIs(route) - assertEquals(false, flow.skipContacts) - } - - // -- Path 3: App resume (PostAccessKey, skipContacts = true) -- - - @Test - fun `path 3 - app resume skips verification and contacts when phone linked`() { - val route = resolvePostAccountRoute( - result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = true, skipContacts = true, ) val flow = assertIs(route) @@ -82,23 +46,10 @@ class OnboardingRoutingTest { assertEquals(true, flow.skipContacts) } - @Test - fun `path 3 - app resume routes through verification and skips contacts when phone not linked`() { - val route = resolvePostAccountRoute( - result = OnboardingResult.ProceedToVerification, - hasLinkedPhone = false, - skipContacts = true, - ) - val verification = assertIs(route) - val target = assertIs(verification.target) - assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) - assertEquals(true, target.skipContacts) - } - // -- Completed (no-op) -- @Test - fun `completed result returns null`() { - assertNull(resolvePostAccountRoute(OnboardingResult.Completed, hasLinkedPhone = false)) + fun `Completed returns null`() { + assertNull(resolvePostAccountRoute(OnboardingResult.Completed)) } }