Skip to content
Open
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
55 changes: 48 additions & 7 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<SumsubLevel | null>(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.
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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'
Expand Down
176 changes: 175 additions & 1 deletion src/components/Card/__tests__/cardApply.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { pollUntilApplyAdvances } from '@/components/Card/cardApply.utils'
import { pollUntilApplyAdvances, resolvePostSumsubAction } from '@/components/Card/cardApply.utils'

const noSleep = async () => {}

Expand Down Expand Up @@ -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')
})
})
85 changes: 85 additions & 0 deletions src/components/Card/cardApply.utils.ts
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down Expand Up @@ -35,3 +37,86 @@ export async function pollUntilApplyAdvances<R extends { status: string }>({
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<ApplyForCardResponse>
intervalMs: number
timeoutMs: number
signal?: AbortSignal
sleep?: (ms: number) => Promise<void>
now?: () => number
}): Promise<PostSumsubAction> {
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 }
}
Loading