From 01eadc5de1369946acef6186bbc59cb54f38302b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 12:31:51 +0000 Subject: [PATCH] refactor(card): route every card applicant through provider routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bridge.xyz and Persona are retired as the card identity flow. The backend now refuses a card KYC link on bridge.xyz and answers instead with the issuer and identity provider the applicant's country routes to — Wirex/Sumsub or Rain/Didit, Wirex winning the ~20 markets both issuers serve. `handleProceedToKyc` still forked on `cardIssuer !== CardProvider.BRIDGE`, with a bridge.xyz fall-through behind it that checked a Bridge KYC link, read the cards endorsement off a Bridge customer, and finally opened a Persona inquiry via `/user-kyc-info`. With the backend refusing that link, reaching it could only produce "An error occurred while creating the KYC link". It was already unreachable: `/cards/status` names `bridge` for nobody (no cardCustomer row carries that provider), and `useCardProvider` maps a Bridge card to null rather than to the issuer — which is also why `user-kyc-info` already bounces every card-mode visitor to the Rain screen. So this removes a dead branch, not a live one, and no real user changes path. - The routing branch is now the whole action: one path, always `resolveKycProvider()`. Its body is unchanged apart from de-indenting. - Deletes `kycFlowHelpers.ts` and `endorsementHelpers.ts`, whose exports this fork was the last consumer of (`kycDisplayHelpers` has its own copy of `hasEndorsementPendingReview`). - Drops the `countryStore` subscription, now read imperatively via `getState()` inside the action, along with the imports the fork needed. No error handling was needed for the backend's new CARD_KYC_REQUIRED and CARD_COUNTRY_UNSUPPORTED codes: `createCard` already parses the `{code, message}` envelope into a typed `ApiError`, so the routing message reaches the user. Verified: eslint clean (`npm run lint` exits 0), 53 card-flow tests passing. The remaining tsc errors and the skipTheLine failure reproduce on master. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YZJ9jHw9M8dFvfCtApNAYA --- hooks/useCardSteps/endorsementHelpers.ts | 218 ---------------------- hooks/useCardSteps/kycFlowHelpers.ts | 165 ----------------- hooks/useCardSteps/useCardSteps.ts | 223 ++++++++--------------- 3 files changed, 79 insertions(+), 527 deletions(-) delete mode 100644 hooks/useCardSteps/endorsementHelpers.ts delete mode 100644 hooks/useCardSteps/kycFlowHelpers.ts diff --git a/hooks/useCardSteps/endorsementHelpers.ts b/hooks/useCardSteps/endorsementHelpers.ts deleted file mode 100644 index 5de6a50db..000000000 --- a/hooks/useCardSteps/endorsementHelpers.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { EndorsementStatus } from '@/components/BankTransfer/enums'; -import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { track } from '@/lib/analytics'; -import { - BridgeCustomerEndorsement, - BridgeEndorsementIssue, - BridgeRejectionReason, -} from '@/lib/types'; - -// ============================================================================ -// Issue Formatting Functions -// ============================================================================ - -/** - * Format a code string to be user-friendly (replace underscores, capitalize) - */ -function formatCodeToReadable(code: string): string { - return code.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); -} - -/** - * Format a single endorsement issue for display - */ -function formatEndorsementIssue(issue: BridgeEndorsementIssue): string { - if (typeof issue === 'string') { - return formatCodeToReadable(issue); - } - // For objects like { id_front_photo: "id_expired" } - return Object.entries(issue) - .map(([field, error]) => `${formatCodeToReadable(field)}: ${formatCodeToReadable(error)}`) - .join('. '); -} - -/** - * Extract user-friendly messages from rejection reasons - */ -function formatRejectionReasons(rejectionReasons: BridgeRejectionReason[]): string[] { - return rejectionReasons.map(r => r.reason).filter(r => r && r.trim().length > 0); -} - -/** - * Build issue message - prioritizes rejection reasons over endorsement issues - */ -function buildIssueMessage( - rejectionReasons: BridgeRejectionReason[] | undefined, - endorsementIssues: BridgeEndorsementIssue[], -): string { - // Prefer rejection reasons (user-friendly from Bridge) - if (rejectionReasons && rejectionReasons.length > 0) { - const messages = formatRejectionReasons(rejectionReasons); - if (messages.length > 0) { - return messages.join('. '); - } - } - - // Fall back to sanitized endorsement issues - if (endorsementIssues.length > 0) { - return endorsementIssues.map(formatEndorsementIssue).join('. '); - } - - return 'Additional verification required.'; -} - -/** - * Build a user-friendly message for incomplete endorsement requirements - */ -export function buildIncompleteEndorsementMessage( - missingKeys: string[], - issues: BridgeEndorsementIssue[], - rejectionReasons?: BridgeRejectionReason[], -): string { - const parts: string[] = []; - - if (missingKeys.length > 0) { - const formattedMissing = missingKeys.map(formatCodeToReadable).join(', '); - parts.push(`Missing: ${formattedMissing}`); - } - - // Use consolidated issue message - const issueMessage = buildIssueMessage(rejectionReasons, issues); - if (issueMessage !== 'Additional verification required.') { - parts.push(issueMessage); - } - - return parts.length > 0 ? parts.join('. ') : 'Additional verification required.'; -} - -// ============================================================================ -// Endorsement Status Checks -// ============================================================================ - -/** - * Check if endorsement has pending requirements (under review) - */ -export function hasEndorsementPendingReview(cardsEndorsement: BridgeCustomerEndorsement): boolean { - const pending = cardsEndorsement.requirements?.pending; - return Array.isArray(pending) && pending.length > 0; -} - -/** - * Check if user can order a card (only when cards endorsement is approved) - */ -export function canOrderCard(cardsEndorsement: BridgeCustomerEndorsement | undefined): boolean { - return cardsEndorsement?.status === EndorsementStatus.APPROVED; -} - -// ============================================================================ -// Analytics Tracking Functions -// ============================================================================ - -/** - * Track analytics for approved cards endorsement - */ -function trackApprovedEndorsement(kycLinkId: string | null): void { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'blocked', - reason: 'approved_with_endorsement', - kycLinkId, - }); -} - -/** - * Track analytics for endorsement with pending review - */ -function trackPendingReviewEndorsement( - cardsEndorsement: BridgeCustomerEndorsement, - kycLinkId: string | null, -): void { - const pendingItems = cardsEndorsement.requirements?.pending || []; - - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'endorsement_pending_review', - kycLinkId, - pendingItems, - }); -} - -/** - * Track analytics for revoked cards endorsement - */ -function trackRevokedEndorsement( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - kycLinkId: string | null, - rejectionReasons?: BridgeRejectionReason[], -): void { - const issues = cardsEndorsement?.requirements?.issues || []; - - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'endorsement_revoked', - kycLinkId, - issues: issues.map(i => (typeof i === 'string' ? i : Object.keys(i).join(','))), - hasRejectionReasons: Boolean(rejectionReasons?.length), - }); -} - -/** - * Track analytics for incomplete cards endorsement with missing requirements - */ -function trackIncompleteEndorsement( - cardsEndorsement: BridgeCustomerEndorsement, - kycLinkId: string | null, - rejectionReasons?: BridgeRejectionReason[], -): void { - const requirements = cardsEndorsement.requirements; - const missingKeys = requirements?.missing ? Object.keys(requirements.missing) : []; - const issues = requirements?.issues || []; - - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'incomplete_endorsement', - kycLinkId, - missingRequirements: missingKeys, - issues: issues.map(i => (typeof i === 'string' ? i : Object.keys(i).join(','))), - hasRejectionReasons: Boolean(rejectionReasons?.length), - }); -} - -// ============================================================================ -// Main Processing Function -// ============================================================================ - -/** - * Determines if the KYC flow should stop based on endorsement status. - * Also tracks analytics for each endorsement state. - * - * @returns true if flow should STOP (approved or pending review), false to CONTINUE to KYC - */ -export function shouldStopKycFlow( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - kycLinkId: string | null, - rejectionReasons?: BridgeRejectionReason[], -): boolean { - // APPROVED: Stop - user can order card, no need for KYC - if (cardsEndorsement?.status === EndorsementStatus.APPROVED) { - trackApprovedEndorsement(kycLinkId); - return true; - } - - // REVOKED: Continue - user can retry KYC with another nationality - if (cardsEndorsement?.status === EndorsementStatus.REVOKED) { - trackRevokedEndorsement(cardsEndorsement, kycLinkId, rejectionReasons); - return false; - } - - // INCOMPLETE: Check if pending review or needs action - if (cardsEndorsement?.status === EndorsementStatus.INCOMPLETE) { - if (hasEndorsementPendingReview(cardsEndorsement)) { - // Pending review: Stop - user should wait - trackPendingReviewEndorsement(cardsEndorsement, kycLinkId); - return true; - } - // Missing requirements: Continue - user can complete KYC - trackIncompleteEndorsement(cardsEndorsement, kycLinkId, rejectionReasons); - return false; - } - - // No endorsement or unknown status: Continue to KYC - return false; -} diff --git a/hooks/useCardSteps/kycFlowHelpers.ts b/hooks/useCardSteps/kycFlowHelpers.ts deleted file mode 100644 index 5bb5648af..000000000 --- a/hooks/useCardSteps/kycFlowHelpers.ts +++ /dev/null @@ -1,165 +0,0 @@ -import Toast from 'react-native-toast-message'; -import { Router } from 'expo-router'; - -import { Endorsements } from '@/components/BankTransfer/enums'; -import { KycMode } from '@/components/UserKyc'; -import { path } from '@/constants/path'; -import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { track } from '@/lib/analytics'; -import { getKycLinkForExistingCustomer } from '@/lib/api'; -import { KycStatus, RainConsumerType } from '@/lib/types'; -import { withRefreshToken } from '@/lib/utils'; -import { checkCountryAccessForKyc, startKycFlow } from '@/lib/utils/kyc'; - -/** - * Build the redirect URI for KYC completion - */ -export function buildKycRedirectUri(): string { - const baseUrl = process.env.EXPO_PUBLIC_BASE_URL; - return `${baseUrl}${path.CARD_ACTIVATE}?kycStatus=${KycStatus.UNDER_REVIEW}`; -} - -/** - * Check country access and block if not available - * @returns true if blocked (country not supported) - */ -export async function checkAndBlockForCountryAccess( - countryStore: { - countryInfo: { isAvailable: boolean; countryCode?: string; source?: string } | null; - }, - kycLinkId: string | null, -): Promise { - // Trust country info if user already confirmed a supported country (manual or IP-based) - if (countryStore.countryInfo?.isAvailable) { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'country_check_skipped', - reason: 'country_already_confirmed', - kycLinkId, - countryCode: countryStore.countryInfo.countryCode, - source: countryStore.countryInfo.source, - }); - return false; // Not blocked - } - - const countryCheck = await checkCountryAccessForKyc(); - - if (!countryCheck.isAvailable) { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'blocked', - reason: 'country_not_supported', - kycLinkId, - countryCode: countryCheck.countryCode, - countryName: countryCheck.countryName, - }); - - Toast.show({ - type: 'error', - text1: 'Country not supported', - text2: countryCheck.countryName - ? `Unfortunately, Solid card isn't available in ${countryCheck.countryName} yet.` - : 'Unfortunately, Solid card is not available in your location yet.', - props: { badgeText: '' }, - }); - return true; - } - - return false; -} - -/** - * Show KYC under review toast and track - */ -export function showKycUnderReviewToast(kycLinkId: string | null): void { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'blocked', - reason: 'under_review', - kycLinkId, - }); - Toast.show({ - type: 'info', - text1: 'KYC under review', - text2: 'Please wait while we complete the review.', - props: { badgeText: '' }, - }); -} - -/** - * Show account offboarded toast and track - */ -export function showAccountOffboardedToast(kycLinkId: string | null): void { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'blocked', - reason: 'offboarded', - kycLinkId, - }); - Toast.show({ - type: 'error', - text1: 'Account offboarded', - text2: 'Please contact support for assistance.', - props: { badgeText: '' }, - }); -} - -/** - * Redirect to existing customer KYC link for cards endorsement - * @returns true if redirect succeeded - */ -export async function redirectToExistingCustomerKycLink( - router: Router, - kycLinkId: string | null, -): Promise { - const redirectUri = buildKycRedirectUri(); - - try { - const existingCustomerKycLink = await withRefreshToken(() => - getKycLinkForExistingCustomer({ - endorsement: Endorsements.CARDS, - redirectUri, - }), - ); - - if (!existingCustomerKycLink) { - throw new Error('Failed to get KYC link for existing customer'); - } - - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'redirect', - method: 'existing_customer_endorsement', - kycLinkId, - kycUrl: existingCustomerKycLink.url, - }); - - startKycFlow({ router, kycLink: existingCustomerKycLink.url }); - return true; - } catch (error) { - console.error('Failed to get KYC link for existing customer:', error); - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'existing_customer_link_failed', - kycLinkId, - }); - return false; - } -} - -/** - * Redirect to collect user info for new KYC - * @param countryCode - */ -export function redirectToCollectUserInfo(router: Router, countryCode?: string): void { - const redirectUri = buildKycRedirectUri(); - const consumerType = - countryCode?.toUpperCase() === 'US' ? RainConsumerType.US : RainConsumerType.INTERNATIONAL; - const params = new URLSearchParams({ - kycMode: KycMode.CARD, - endorsement: Endorsements.CARDS, - redirectUri, - consumerType, - }).toString(); - - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'redirect', - method: 'collect_user_info', - consumerType, - }); - router.push(`/user-kyc-info?${params}`); -} diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 8b35ae32b..e3dd94405 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -12,7 +12,7 @@ import { useCustomer, useKycLinkFromBridge } from '@/hooks/useCustomer'; import { useOpenDepositFlow } from '@/hooks/useOpenDepositFlow'; import { useProspectiveCardIssuer } from '@/hooks/useProspectiveCardIssuer'; import { track } from '@/lib/analytics'; -import { getCustomerFromBridge, getKycLinkFromBridge, resumeRainKycForward } from '@/lib/api'; +import { resumeRainKycForward } from '@/lib/api'; import { EXPO_PUBLIC_CARD_ISSUER } from '@/lib/config'; import { resolveKycProvider } from '@/lib/kycProviderRouting'; import { redirectToRainVerification } from '@/lib/rainVerification'; @@ -24,14 +24,6 @@ import { useKycStore } from '@/store/useKycStore'; import { openSupportDrawer } from '@/store/useSupportDrawerStore'; // Import helpers -import { shouldStopKycFlow } from './endorsementHelpers'; -import { - checkAndBlockForCountryAccess, - redirectToCollectUserInfo, - redirectToExistingCustomerKycLink, - showAccountOffboardedToast, - showKycUnderReviewToast, -} from './kycFlowHelpers'; import { computeKycStatus, computeUiKycStatus, useProcessingWindow } from './kycStatusHelpers'; import { resolveRainKycAction } from './rainKycAction'; import { openCardSavingsDeposit } from './savingsDepositEntry'; @@ -77,7 +69,6 @@ export function useCardSteps( cardStatusResponse?.applicationExternalVerificationLink != null ? CardProvider.RAIN : (cardStatusResponse?.provider ?? EXPO_PUBLIC_CARD_ISSUER ?? null); - const countryStore = useCountryStore(useShallow(state => ({ countryInfo: state.countryInfo }))); // Get customer data with cards endorsement const { data: customer } = useCustomer(); @@ -227,167 +218,111 @@ export function useCardSteps( cardIssuer, }); - // Non-Bridge users go through Didit (Rain) by default. The backend is - // authoritative for whether this jurisdiction should instead use Sumsub - // (Wirex). Defaulting to Didit — and staying there if the call fails — keeps - // the widely-available flow as the safe fallback. + // EVERY card applicant routes here. The backend is authoritative for which + // jurisdiction gets Sumsub (Wirex) and which gets Didit (Rain), and Wirex + // wins the markets both issuers serve. Defaulting to Didit — and staying + // there if the call fails — keeps the widely-available flow as the safe + // fallback. + // + // This used to be the `cardIssuer !== BRIDGE` branch, with a bridge.xyz + // fall-through behind it that ran a Persona inquiry. Both are retired: the + // backend refuses a card KYC link on bridge.xyz and answers with the issuer + // and identity provider the user's country routes to instead, so reaching + // that fall-through could only produce "An error occurred while creating the + // KYC link". It was already unreachable — `/cards/status` names `bridge` for + // nobody, and `useCardProvider` maps a Bridge card to null rather than to + // the issuer — so removing it changes nothing for any real user and leaves + // one path through this action. // // The country is resolved inside `resolveKycProvider`, not read from this // closure: entry points run the country gate and then call this action in // the same tick, so a country captured at render time is still the pre-gate // one and a Wirex user would be sent to Didit on their first press. - if (cardIssuer !== CardProvider.BRIDGE) { - setKycFlow('card'); - const { kycProvider, countryCode } = await resolveKycProvider(); - setKycProvider(kycProvider); + setKycFlow('card'); + const { kycProvider, countryCode } = await resolveKycProvider(); + setKycProvider(kycProvider); + track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { + action: 'route', + kycProvider, + countryCode, + }); + + /** + * No country at all — neither stored nor detectable. `resolveKycProvider` + * answers Didit here because Didit is available everywhere, and that + * fallback is how users in markets Wirex wins (Thailand, Brazil, the US) + * ended up on Rain permanently: the Didit session creates a card customer + * that defaults to Rain, and nothing ever rewrites it. + * + * Ask instead of guessing. This is the same screen the Sumsub branch + * below uses, and it is only reached when the IP lookup failed too — a + * country that resolved keeps going without an extra step. + */ + if (!countryCode) { track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'route', + action: 'country_selection_required', kycProvider, - countryCode, + reason: 'country_unresolved', }); + router.push(path.CARD_COUNTRY_SELECTION as any); + return; + } + if (kycProvider === KycProvider.SUMSUB) { /** - * No country at all — neither stored nor detectable. `resolveKycProvider` - * answers Didit here because Didit is available everywhere, and that - * fallback is how users in markets Wirex wins (Thailand, Brazil, the US) - * ended up on Rain permanently: the Didit session creates a card customer - * that defaults to Rain, and nothing ever rewrites it. + * A verification that has already been submitted must not be restarted. + * Sumsub GREEN hands off to Wirex, which then adjudicates, so an + * UNDER_REVIEW user is mid-hand-off — and if that forward stalled (a + * Wirex outage; see the admin retry endpoint) they would sit here + * pressing a button that opens a brand new session, hit the country + * gate on it, and be told to confirm a country to redo work they had + * already finished. * - * Ask instead of guessing. This is the same screen the Sumsub branch - * below uses, and it is only reached when the IP lookup failed too — a - * country that resolved keeps going without an extra step. + * `kycStatus`, not `uiKycStatus`: the latter can be an optimistic + * post-submit window, while this needs the backend's own answer — and + * for a Wirex user there is no Bridge kycLink, so it is exactly what + * /cards/status reported. */ - if (!countryCode) { + if (kycStatus === KycStatus.UNDER_REVIEW) { track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'country_selection_required', + action: 'already_under_review', kycProvider, - reason: 'country_unresolved', }); - router.push(path.CARD_COUNTRY_SELECTION as any); + router.push(path.CARD_PENDING as any); return; } - if (kycProvider === KycProvider.SUMSUB) { - /** - * A verification that has already been submitted must not be restarted. - * Sumsub GREEN hands off to Wirex, which then adjudicates, so an - * UNDER_REVIEW user is mid-hand-off — and if that forward stalled (a - * Wirex outage; see the admin retry endpoint) they would sit here - * pressing a button that opens a brand new session, hit the country - * gate on it, and be told to confirm a country to redo work they had - * already finished. The Bridge branch below has always guarded this; - * the Sumsub path did not. - * - * `kycStatus`, not `uiKycStatus`: the latter can be an optimistic - * post-submit window, while this needs the backend's own answer — and - * for a Wirex user there is no Bridge kycLink, so it is exactly what - * /cards/status reported. - */ - if (kycStatus === KycStatus.UNDER_REVIEW) { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'already_under_review', - kycProvider, - }); - router.push(path.CARD_PENDING as any); - return; - } - - /** - * Sumsub card sessions REQUIRE a country the user declared themselves — - * an IP guess is refused server-side, because `user.country` pins which - * issuer serves them and nothing writes it back. Ask here rather than - * letting the session fail: this is the entry point the home setup step - * uses (`useHomeSetupSteps` calls this action directly rather than - * `startCardOnboarding`), so it is reached with no gate having run at - * all, and `resolveKycProvider` deliberately does not persist the - * country it routes on. - * - * The selection screen writes `source: 'manual'` and re-enters the flow - * via `/card/activate?countryConfirmed=true`, so this asks once. - */ - if (useCountryStore.getState().countryInfo?.source !== 'manual') { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'country_selection_required', - kycProvider, - countryCode, - }); - router.push(path.CARD_COUNTRY_SELECTION as any); - return; - } - } - - router.push((kycProvider === KycProvider.SUMSUB ? path.SUMSUB_KYC : path.KYC) as any); - return; - } - - setKycFlow('card'); - - // Check country access (Bridge flow) - const isBlocked = await checkAndBlockForCountryAccess(countryStore, kycLinkId); - if (isBlocked) return; - - // Check latest KYC status (Bridge) - try { - if (kycLinkId) { - const latest = await withRefreshToken(() => getKycLinkFromBridge(kycLinkId)); - const latestStatus = (latest?.kyc_status as KycStatus) || KycStatus.NOT_STARTED; - - if (latestStatus === KycStatus.UNDER_REVIEW) { - showKycUnderReviewToast(kycLinkId); - return; - } - - if (latestStatus === KycStatus.OFFBOARDED) { - showAccountOffboardedToast(kycLinkId); - return; - } - - // KYC link approved, but we need to check cards endorsement status - // (KYC approval ≠ cards endorsement approval - they can differ) - if (latestStatus === KycStatus.APPROVED) { - const latestCustomer = await withRefreshToken(() => getCustomerFromBridge()); - const latestCardsEndorsement = latestCustomer?.endorsements?.find( - e => e.name === 'cards', - ); - - // Check cards endorsement status: - // - APPROVED: stop - user can order card - // - PENDING REVIEW: stop - user should wait - // - REVOKED/INCOMPLETE/None: continue - user needs to retry KYC for cards - const stopFlow = shouldStopKycFlow( - latestCardsEndorsement, - kycLinkId, - latestCustomer?.rejection_reasons, - ); - - if (stopFlow) return; - - // Edge case: KYC approved but cards endorsement not approved - // Redirect user to complete KYC specifically for cards endorsement - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { - action: 'approved_missing_endorsement', - kycLinkId, - hasCardsEndorsement: Boolean(latestCardsEndorsement), - cardsEndorsementStatus: latestCardsEndorsement?.status, - }); - - if (await redirectToExistingCustomerKycLink(router, kycLinkId)) return; - } + /** + * Sumsub card sessions REQUIRE a country the user declared themselves — + * an IP guess is refused server-side, because `user.country` pins which + * issuer serves them and nothing writes it back. Ask here rather than + * letting the session fail: this is the entry point the home setup step + * uses (`useHomeSetupSteps` calls this action directly rather than + * `startCardOnboarding`), so it is reached with no gate having run at + * all, and `resolveKycProvider` deliberately does not persist the + * country it routes on. + * + * The selection screen writes `source: 'manual'` and re-enters the flow + * via `/card/activate?countryConfirmed=true`, so this asks once. + */ + if (useCountryStore.getState().countryInfo?.source !== 'manual') { + track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { + action: 'country_selection_required', + kycProvider, + countryCode, + }); + router.push(path.CARD_COUNTRY_SELECTION as any); + return; } - } catch { - track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { action: 'status_check_failed', kycLinkId }); } - // Try to get a fresh KYC URL with redirect_uri, or fall back to user info collection - if (await redirectToExistingCustomerKycLink(router, kycLinkId)) return; - redirectToCollectUserInfo(router, countryStore.countryInfo?.countryCode); + router.push((kycProvider === KycProvider.SUMSUB ? path.SUMSUB_KYC : path.KYC) as any); }, [ router, kycLinkId, kycStatus, uiKycStatus, processingUntil, - countryStore, cardsEndorsement?.status, cardIssuer, setKycFlow,