diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 0e37a72b5..0e837e2bb 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -8,7 +8,7 @@ import { cardApi, type CardInfoResponse } from '@/services/card' import { useAuth } from '@/context/authContext' import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview' import { computeCardState, findActiveCard, type CardTopLevelState } from '@/components/Card/cardState.utils' -import { pollUntilApplyAdvances } from '@/components/Card/cardApply.utils' +import { resolvePostSumsubAction, type SumsubLevel } from '@/components/Card/cardApply.utils' import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' import CardTermsScreen from '@/components/Card/CardTermsScreen' @@ -238,6 +238,11 @@ const CardPage: FC = () => { void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) }, [queryClient]) + // Tracks the level the WebSDK was last opened at, so the post-Sumsub + // resolver can tell a level transition (main → rain-card-application) + // apart from "Sumsub auto-review still pending". See `resolvePostSumsubAction`. + const lastSumsubLevelRef = useRef(null) + // Routes a non-incomplete apply response to the right next screen. Shared // by the user-initiated apply path and the post-Sumsub poll, since both // need the same main-kyc-required / terms-required / default fan-out. @@ -251,6 +256,7 @@ const CardPage: FC = () => { // wrapper handles both action and main-level tokens. if (res.status === 'main-kyc-required' && 'sumsubAccessToken' in res) { setSumsubToken(res.sumsubAccessToken) + lastSumsubLevelRef.current = 'main' posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED) return } @@ -277,6 +283,7 @@ const CardPage: FC = () => { posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { outcome: res.status }) if (res.status === 'incomplete' && 'sumsubAccessToken' in res) { setSumsubToken(res.sumsubAccessToken) + lastSumsubLevelRef.current = 'rain-card-application' posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED) return } @@ -350,6 +357,10 @@ const CardPage: FC = () => { const handleSumsubComplete = useCallback(async () => { sumsubCompletedRef.current = true + // Snapshot which level the WebSDK was closed at — `resolvePostSumsubAction` + // needs it to tell a level transition apart from "still pending review". + // Read before clearing the token so a re-render race can't clobber it. + const justCompletedLevel = lastSumsubLevelRef.current posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_COMPLETED) setSumsubToken(null) setApplyError(null) @@ -360,19 +371,49 @@ const CardPage: FC = () => { pollAbortRef.current = controller try { - const res = await pollUntilApplyAdvances({ + const action = await resolvePostSumsubAction({ + justCompletedLevel, fetchApply: () => rainApi.applyForCard({ termsAccepted: false }), intervalMs: 1000, timeoutMs: 15000, signal: controller.signal, }) if (controller.signal.aborted) return - if (!res) { - setApplyError('Verification is taking longer than expected. Please try again.') - return + switch (action.kind) { + case 'reopen-websdk': + // The user just finished MAIN; BE now wants the + // rain-card-application questionnaire. Open the WebSDK + // again at the new level instead of polling-to-timeout. + // Reset the completion flag — handleSumsubClose uses it + // to gate the CARD_SUMSUB_CLOSED abandonment metric, and + // closing the REOPENED level without finishing should + // count as abandonment of the questionnaire. + sumsubCompletedRef.current = false + setSumsubToken(action.sumsubAccessToken) + lastSumsubLevelRef.current = action.level + posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED) + return + case 'advance': + posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { + outcome: action.response.status, + }) + advanceFromApplyResponse(action.response) + return + case 'timeout': + setApplyError('Verification is taking longer than expected. Please try again.') + return + case 'aborted': + return + default: { + // Compile-time exhaustiveness guard: adding a new + // PostSumsubAction variant without a matching case + // becomes a type error here instead of a silent + // fall-through at runtime. + const _exhaustive: never = action + void _exhaustive + return + } } - posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { outcome: res.status }) - advanceFromApplyResponse(res) } catch (e) { if (controller.signal.aborted) return const message = e instanceof Error ? e.message : 'Failed to apply for card' diff --git a/src/components/Card/__tests__/cardApply.utils.test.ts b/src/components/Card/__tests__/cardApply.utils.test.ts index 117875f55..bc2411feb 100644 --- a/src/components/Card/__tests__/cardApply.utils.test.ts +++ b/src/components/Card/__tests__/cardApply.utils.test.ts @@ -1,4 +1,4 @@ -import { pollUntilApplyAdvances } from '@/components/Card/cardApply.utils' +import { pollUntilApplyAdvances, resolvePostSumsubAction } from '@/components/Card/cardApply.utils' const noSleep = async () => {} @@ -109,3 +109,177 @@ describe('pollUntilApplyAdvances', () => { expect(fetchApply).not.toHaveBeenCalled() }) }) + +describe('resolvePostSumsubAction', () => { + // The bug this resolver fixes: a user finishes the MAIN-level WebSDK + // (e.g. Rain-required SELFIE), the BE's next /rain/cards call returns + // `incomplete` + new token at `rain-card-application`. The old code + // polled "until status != incomplete" and timed out, showing "taking + // longer than expected" instead of reopening the WebSDK at the new + // level. This test pins the level-transition fix. + it('reopens the WebSDK at rain-card-application when MAIN just completed', async () => { + const fetchApply = jest.fn().mockResolvedValueOnce({ + status: 'incomplete', + missing: ['questionnaire'], + questionnaireComplete: false, + sumsubAccessToken: 'next-level-token', + }) + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'main', + fetchApply, + intervalMs: 1000, + timeoutMs: 15000, + sleep: noSleep, + }) + + expect(result).toEqual({ + kind: 'reopen-websdk', + sumsubAccessToken: 'next-level-token', + level: 'rain-card-application', + }) + // MAIN branch is a single fetch, not a poll — calling fetchApply + // more than once would mean we lost the discriminator and slipped + // back into the timeout-prone loop. + expect(fetchApply).toHaveBeenCalledTimes(1) + }) + + it('advances when MAIN just completed but BE skipped straight to terms-required', async () => { + // Edge case: a returning user already has the action questionnaire + // filled (questionnaireProcessingSettings.skipIfFilled=true). After + // they re-submit the MAIN selfie, the BE skips the action level and + // returns terms-required directly. The resolver must advance, not + // try to reopen a WebSDK with no token. + const fetchApply = jest.fn().mockResolvedValueOnce({ + status: 'terms-required', + isUsResident: false, + termsVersion: '2026-04-21', + }) + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'main', + fetchApply, + intervalMs: 1000, + timeoutMs: 15000, + sleep: noSleep, + }) + + expect(result).toEqual({ + kind: 'advance', + response: { status: 'terms-required', isUsResident: false, termsVersion: '2026-04-21' }, + }) + expect(fetchApply).toHaveBeenCalledTimes(1) + }) + + it('polls until the response advances when rain-card-application just completed', async () => { + // Action-level path: Sumsub auto-review for the questionnaire takes + // a beat, so `incomplete` here genuinely means "wait" — not a level + // transition. Same poll loop the old code used. + const fetchApply = jest + .fn() + .mockResolvedValueOnce({ status: 'incomplete', sumsubAccessToken: 't1' }) + .mockResolvedValueOnce({ status: 'terms-required', isUsResident: false, termsVersion: 'v' }) + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'rain-card-application', + fetchApply, + intervalMs: 0, + timeoutMs: 10_000, + sleep: noSleep, + }) + + expect(result).toEqual({ + kind: 'advance', + response: { status: 'terms-required', isUsResident: false, termsVersion: 'v' }, + }) + expect(fetchApply).toHaveBeenCalledTimes(2) + }) + + it('returns timeout when the rain-card-application poll runs out the clock', async () => { + const fetchApply = jest.fn().mockResolvedValue({ status: 'incomplete', sumsubAccessToken: 't' }) + let clock = 0 + const tick = (ms: number) => { + clock += ms + } + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'rain-card-application', + fetchApply, + intervalMs: 1000, + timeoutMs: 3000, + sleep: async (ms) => tick(ms), + now: () => clock, + }) + + expect(result).toEqual({ kind: 'timeout' }) + }) + + it('returns aborted when the signal is already aborted on entry (MAIN path)', async () => { + const controller = new AbortController() + controller.abort() + const fetchApply = jest.fn() + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'main', + fetchApply, + intervalMs: 0, + timeoutMs: 10_000, + signal: controller.signal, + sleep: noSleep, + }) + + expect(result).toEqual({ kind: 'aborted' }) + expect(fetchApply).not.toHaveBeenCalled() + }) + + it('returns aborted when the signal fires mid-poll (rain-card-application path)', async () => { + const controller = new AbortController() + const fetchApply = jest.fn().mockImplementation(async () => { + if (fetchApply.mock.calls.length === 2) controller.abort() + return { status: 'incomplete', sumsubAccessToken: 't' } + }) + + const result = await resolvePostSumsubAction({ + justCompletedLevel: 'rain-card-application', + fetchApply, + intervalMs: 0, + timeoutMs: 10_000, + signal: controller.signal, + sleep: noSleep, + }) + + expect(result).toEqual({ kind: 'aborted' }) + }) + + it('treats a null justCompletedLevel like rain-card-application (poll, no MAIN fast-path)', async () => { + // The component clears the ref to null only in initial mount state. + // If Sumsub fires onComplete before the ref is stamped (shouldn't + // happen, but defending the boundary), fall back to the poll path + // so we don't false-positive a reopen on a regular incomplete. + const fetchApply = jest.fn().mockResolvedValueOnce({ status: 'pending', rainUserId: 'u', message: 'ok' }) + + const result = await resolvePostSumsubAction({ + justCompletedLevel: null, + fetchApply, + intervalMs: 0, + timeoutMs: 10_000, + sleep: noSleep, + }) + + expect(result).toEqual({ kind: 'advance', response: { status: 'pending', rainUserId: 'u', message: 'ok' } }) + }) + + it('propagates errors from fetchApply on the MAIN path', async () => { + const fetchApply = jest.fn().mockRejectedValue(new Error('network down')) + + await expect( + resolvePostSumsubAction({ + justCompletedLevel: 'main', + fetchApply, + intervalMs: 0, + timeoutMs: 10_000, + sleep: noSleep, + }) + ).rejects.toThrow('network down') + }) +}) diff --git a/src/components/Card/cardApply.utils.ts b/src/components/Card/cardApply.utils.ts index 9787babe2..776f8a713 100644 --- a/src/components/Card/cardApply.utils.ts +++ b/src/components/Card/cardApply.utils.ts @@ -1,3 +1,5 @@ +import type { ApplyForCardResponse } from '@/services/rain' + /** * Sumsub's auto-review usually settles in well under a second, but sometimes * lags. Poll the apply endpoint until backend stops returning `incomplete`, @@ -35,3 +37,86 @@ export async function pollUntilApplyAdvances({ if (now() - start >= timeoutMs) return null } } + +/** + * Sumsub level the WebSDK was just opened at. Tracked at the call site so + * `resolvePostSumsubAction` can tell a level transition apart from "Sumsub + * auto-review still pending". + */ +export type SumsubLevel = 'main' | 'rain-card-application' + +/** + * What the caller should do after the Sumsub WebSDK closes successfully. + * + * - `reopen-websdk`: the user just completed one Sumsub level and the BE has + * moved on to the next one (e.g. MAIN selfie done → card-application + * questionnaire pending). Reopen the WebSDK with the new token. + * - `advance`: the apply response has settled — route on `response.status`. + * - `timeout`: poll hit the deadline without the response advancing. + * - `aborted`: the abort signal fired before the resolution completed. + */ +export type PostSumsubAction = + | { kind: 'reopen-websdk'; sumsubAccessToken: string; level: SumsubLevel } + | { kind: 'advance'; response: ApplyForCardResponse } + | { kind: 'timeout' } + | { kind: 'aborted' } + +/** + * Resolve what to do after the Sumsub WebSDK fires `onComplete`. + * + * The card application flow can require TWO Sumsub levels back-to-back: + * + * 1. `main` — main applicant docs Rain requires (e.g. SELFIE after liveness + * was added to the Sumsub level config) + * 2. `rain-card-application` — the card-specific questionnaire (occupation, + * annual salary, account purpose, expected monthly volume) + * + * When the user finishes step 1, the BE's next `/rain/cards` call returns + * `status: 'incomplete'` with a new token at level 2 — that's a LEVEL + * TRANSITION, not "Sumsub auto-review still pending". Reading `incomplete` + * as "keep polling" leaves the user stuck on a 15s timeout screen even + * though the BE is already telling us what to do next. `resolvePostSumsubAction` + * branches on the level the WebSDK was just closed at: + * + * - just-completed `main`: single fetch, expect `incomplete` + new token, + * reopen the WebSDK at `rain-card-application`. Falls through to `advance` + * in the edge case where the BE skips straight to `terms-required` (e.g. + * questionnaire was already filled in a prior attempt). + * - just-completed `rain-card-application` (or unknown): poll until the + * response advances past `incomplete` — Sumsub auto-review for the + * questionnaire is the only thing the response is waiting on. + */ +export async function resolvePostSumsubAction({ + justCompletedLevel, + fetchApply, + intervalMs, + timeoutMs, + signal, + sleep = (ms) => new Promise((r) => setTimeout(r, ms)), + now = () => Date.now(), +}: { + justCompletedLevel: SumsubLevel | null + fetchApply: () => Promise + intervalMs: number + timeoutMs: number + signal?: AbortSignal + sleep?: (ms: number) => Promise + now?: () => number +}): Promise { + if (justCompletedLevel === 'main') { + if (signal?.aborted) return { kind: 'aborted' } + const res = await fetchApply() + if (signal?.aborted) return { kind: 'aborted' } + if (res.status === 'incomplete' && 'sumsubAccessToken' in res) { + return { + kind: 'reopen-websdk', + sumsubAccessToken: res.sumsubAccessToken, + level: 'rain-card-application', + } + } + return { kind: 'advance', response: res } + } + const res = await pollUntilApplyAdvances({ fetchApply, intervalMs, timeoutMs, signal, sleep, now }) + if (res === null) return signal?.aborted ? { kind: 'aborted' } : { kind: 'timeout' } + return { kind: 'advance', response: res } +}