From 5278767c878521046628c7445299fb749781c190 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 07:34:21 +0000 Subject: [PATCH] fix(kyc): stop the Didit fallback pinning users to the wrong card issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card flow falls back to Didit whenever it cannot resolve a country or the routing call fails, because Didit is available everywhere. That was safe while the server questioned nothing — and it was not: a Didit session creates a card customer defaulting to Rain, and nothing ever rewrites it. Users in markets Wirex is supposed to win (Thailand, Brazil, Kenya, the US) were landing on Rain for good on the strength of one failed IP lookup. Three changes, matching the server guard that now refuses these: - The card flow sends its country on the Didit session, resolved by the same `resolveRoutingCountry` the routing call uses — so the server can refuse a Wirex market. Both callers must agree or the client is routed to Didit and then refused by it. The onramp and virtual account send none: they run off their own country lists and are not card applications. - CARD_PROVIDER_MISMATCH sends the user to the country screen, exactly as the Sumsub side already does for the mirror case. It re-asks routing on the way through and persists the country picked, so the round trip settles instead of repeating; pushing straight at the Sumsub screen would only bounce off its own COUNTRY_REQUIRED. - With no country resolvable at all, the card flow asks rather than guessing. Only reached when the IP lookup failed too — a country that resolves still goes straight through, so no one gains a step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xq9bCus4z9sv4FH19v1xgu --- components/kyc/useDiditSession.ts | 33 ++++++++++++++++- hooks/useCardSteps/useCardSteps.ts | 21 +++++++++++ lib/__tests__/kycProviderRouting.test.ts | 47 +++++++++++++++++++++++- lib/api.ts | 16 +++++++- lib/assets.ts | 4 ++ lib/kycProviderRouting.ts | 7 +++- 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/components/kyc/useDiditSession.ts b/components/kyc/useDiditSession.ts index a0983c2c..6a3866fa 100644 --- a/components/kyc/useDiditSession.ts +++ b/components/kyc/useDiditSession.ts @@ -10,6 +10,7 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { CARD_STATUS_QUERY_KEY } from '@/hooks/useCardStatus'; import { track } from '@/lib/analytics'; import { createDiditSession, getCardStatus, getDiditVerificationStatus } from '@/lib/api'; +import { resolveRoutingCountry } from '@/lib/kycProviderRouting'; import { KycStatus, RainApplicationStatus } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { useDepositStore } from '@/store/useDepositStore'; @@ -181,7 +182,15 @@ export function useDiditSession() { // Their card application stays gated regardless: the hand-off to the // issuer re-checks the deposit whichever flow started the session. const diditFlow = kycFlow === 'va' ? 'va' : kycFlow === 'transfi' ? 'onramp' : 'card'; - const res = await withRefreshToken(() => createDiditSession(undefined, diditFlow)); + // The card flow sends its country so the server can refuse a market Wirex + // serves — Wirex wins every jurisdiction both issuers cover, and this + // session is what would otherwise pin the user to Rain permanently. Only + // the card asks: the onramp and the virtual account run off their own + // country lists and are not card applications. + const countryCode = diditFlow === 'card' ? await resolveRoutingCountry() : undefined; + const res = await withRefreshToken(() => + createDiditSession(undefined, diditFlow, countryCode), + ); if (!res) { setSession({ phase: 'error', @@ -223,6 +232,28 @@ export function useDiditSession() { } return; } + /** + * CARD_PROVIDER_MISMATCH (400): this user's country routes to Wirex, + * which verifies with Sumsub — Wirex wins every market both issuers + * serve. Didit is the wrong widget for them: a stale build, or a country + * the client could not resolve and defaulted away from. The server + * refused before creating the card customer that would have pinned them + * to Rain for good, since nothing ever rewrites it. + * + * Back to the country screen, exactly as the Sumsub side does for the + * mirror case. It re-asks routing on the way through and lands them on + * Sumsub — and, decisively, persists the country they pick, so the round + * trip settles instead of repeating. Pushing straight at the Sumsub + * screen would only bounce off its own COUNTRY_REQUIRED. + */ + if (e?.code === 'CARD_PROVIDER_MISMATCH') { + track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { + action: 'provider_mismatch', + kycProvider: 'didit', + }); + router.replace(path.CARD_COUNTRY_SELECTION as any); + return; + } // CARD_DEPOSIT_REQUIRED (400): the applicant is not holding the minimum // savings deposit, so the backend refuses to start a verification we would // be charged for. This is not an error state — it is the first step of the diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index dccb2831..8b35ae32 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -246,6 +246,27 @@ export function useCardSteps( 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: 'country_selection_required', + kycProvider, + reason: 'country_unresolved', + }); + router.push(path.CARD_COUNTRY_SELECTION as any); + return; + } + if (kycProvider === KycProvider.SUMSUB) { /** * A verification that has already been submitted must not be restarted. diff --git a/lib/__tests__/kycProviderRouting.test.ts b/lib/__tests__/kycProviderRouting.test.ts index 75c6b3c4..a6dbbe08 100644 --- a/lib/__tests__/kycProviderRouting.test.ts +++ b/lib/__tests__/kycProviderRouting.test.ts @@ -2,7 +2,7 @@ import { getProviderRouting } from '@/lib/api'; import { detectGeo } from '@/lib/geo'; -import { resolveKycProvider } from '@/lib/kycProviderRouting'; +import { resolveKycProvider, resolveRoutingCountry } from '@/lib/kycProviderRouting'; import { CardProvider, KycProvider } from '@/lib/types'; import { useCountryStore } from '@/store/useCountryStore'; @@ -202,3 +202,48 @@ describe('resolveKycProvider flow gating', () => { }); }); }); + +/** + * The Didit session sends this country too, so the server can refuse a card + * application in a market Wirex wins. Both callers have to reach the same + * answer for the same user: route the client to Didit on one country and have + * the session refused on another and the user bounces between widgets. + * + * This is the resolution the misrouted users never had. A Thai account seconds + * old has no stored country and no `user.country` on the server either, so + * whatever this returns is the only thing standing between them and a Rain card + * customer nothing will ever rewrite. + */ +describe('resolveRoutingCountry', () => { + it('uses the stored country without an IP lookup', async () => { + storeCountry('TH'); + + await expect(resolveRoutingCountry()).resolves.toBe('TH'); + expect(mockDetectGeo).not.toHaveBeenCalled(); + }); + + it('falls back to an IP lookup when nothing is stored', async () => { + mockDetectGeo.mockResolvedValue({ countryCode: 'TH', countryName: 'Thailand' }); + + await expect(resolveRoutingCountry()).resolves.toBe('TH'); + }); + + /** + * The genuinely unknowable case. It answers undefined rather than guessing — + * the card flow sends the user to the country selection screen on it, because + * quietly proceeding is what routed these applicants to Rain. + */ + it('answers undefined when the country cannot be resolved at all', async () => { + mockDetectGeo.mockResolvedValue(null); + + await expect(resolveRoutingCountry()).resolves.toBeUndefined(); + }); + + it('does not persist the country it detected', async () => { + mockDetectGeo.mockResolvedValue({ countryCode: 'TH', countryName: 'Thailand' }); + + await resolveRoutingCountry(); + + expect(useCountryStore.getState().countryInfo).toBeNull(); + }); +}); diff --git a/lib/api.ts b/lib/api.ts index 60b5c05c..616d5983 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -745,10 +745,20 @@ export const toApiError = async ( * ungated — buying crypto with fiat is how a fiat-only user funds their savings * in the first place, so requiring a balance to verify for it would leave them * unable to fund the balance it requires. `va` is the virtual-account workflow. + * + * `countryCode` is read for the CARD flow only, and only to be REFUSED on: a + * Didit session is a card application to Rain, and Rain must not take an + * applicant Wirex serves. The backend prefers the user's stored country and + * falls back to this one, which matters for the case that produced the bug — + * an account seconds old has no stored country, so without this the server has + * nothing to check and the applicant is pinned to Rain for good. Nothing here + * is written as the user's residence, so an IP-detected country is safe to + * send; the worst it can do is send a traveller to confirm where they live. */ export const createDiditSession = async ( callback?: string, flow: 'card' | 'va' | 'onramp' = 'card', + countryCode?: string, ): Promise => { const jwt = getJWTToken(); const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/didit/session`, { @@ -759,7 +769,11 @@ export const createDiditSession = async ( ...getPlatformHeaders(), ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }, - body: JSON.stringify({ ...(callback ? { callback } : {}), flow }), + body: JSON.stringify({ + ...(callback ? { callback } : {}), + flow, + ...(countryCode ? { countryCode } : {}), + }), }); if (!response.ok) { throw await toApiError(response, 'Failed to create verification session'); diff --git a/lib/assets.ts b/lib/assets.ts index acf23972..954cd43b 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -1068,6 +1068,10 @@ export const ASSETS = { module: require('@/assets/images/solid-dark-purple.png'), hash: 'e599ecfa', }, + 'images/solid-email-logo.png': { + module: require('@/assets/images/solid-email-logo.png'), + hash: 'bc075f1b', + }, 'images/solid-favicon-180.png': { module: require('@/assets/images/solid-favicon-180.png'), hash: '0b2fb9ee', diff --git a/lib/kycProviderRouting.ts b/lib/kycProviderRouting.ts index 30c51674..f8555cda 100644 --- a/lib/kycProviderRouting.ts +++ b/lib/kycProviderRouting.ts @@ -13,6 +13,11 @@ export interface ResolvedKycProvider { /** * The country to route the KYC provider on, read at call time. * + * Exported because the Didit session sends it too: the backend refuses a card + * session in a country Wirex serves, and it can only do that with a country in + * hand. Both must reach the same answer or the client would be routed to Didit + * and then refused by it. + * * Reading the store here rather than through a `useCountryStore` selector is * what makes this safe to call straight after the card country gate: the gate * persists the country it just resolved, but the React tree has not re-rendered @@ -26,7 +31,7 @@ export interface ResolvedKycProvider { * `resolveCountryAccess` knows that answer. `detectGeo` memoises per session, so * this costs nothing once the gate has run. */ -const resolveRoutingCountry = async (): Promise => { +export const resolveRoutingCountry = async (): Promise => { const stored = useCountryStore.getState().countryInfo?.countryCode; if (stored) return stored;