Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion components/kyc/useDiditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions hooks/useCardSteps/useCardSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion lib/__tests__/kycProviderRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
});
16 changes: 15 additions & 1 deletion lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiditSessionResponse> => {
const jwt = getJWTToken();
const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/didit/session`, {
Expand All @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions lib/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion lib/kycProviderRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string | undefined> => {
export const resolveRoutingCountry = async (): Promise<string | undefined> => {
const stored = useCountryStore.getState().countryInfo?.countryCode;
if (stored) return stored;

Expand Down
Loading