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;