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
45 changes: 35 additions & 10 deletions src/hooks/__tests__/useSumsubKycFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,11 @@ describe('useSumsubKycFlow — terminal-error exits clear the user-initiated gua
// the entire modal-open, re-running provider submissions for approved-LATAM
// self-recovery users (86 in 20 min for one user). The poll now backs off on a
// time-escalating schedule (5s for the first minute, then 10s → 20s → 60s) and
// stops entirely after a ~15 min cap. Backoff is time-based, not error-based:
// the poll returns HTTP 200 even when the backend reprocess fails.
// then holds a steady 60s cadence for as long as the modal is open — it never
// hard-stops, so a late/missed websocket event is always eventually recovered
// (the earlier 15-min cap stranded users on "Almost there"). Backoff is
// time-based, not error-based: the poll returns HTTP 200 even when the backend
// reprocess fails.
describe('useSumsubKycFlow — verification-progress poll backoff', () => {
beforeEach(() => {
jest.useFakeTimers()
Expand Down Expand Up @@ -303,22 +306,44 @@ describe('useSumsubKycFlow — verification-progress poll backoff', () => {
expect(mockInitiate).toHaveBeenCalledTimes(19)
})

it('stops polling entirely after the ~15 min cap', async () => {
it('keeps polling past 15 min (no strand) but stays bounded at the 60s steady cadence', async () => {
openModal()

await act(async () => {
await jest.advanceTimersByTimeAsync(15 * 60_000)
})
const countAtCap = mockInitiate.mock.calls.length
// escalation kept this far below a fixed-5s cadence (~180 calls over 15 min).
expect(countAtCap).toBeGreaterThan(0)
expect(countAtCap).toBeLessThan(40)
const countAt15m = mockInitiate.mock.calls.length
// escalation kept the first 15 min far below a fixed-5s cadence (~180).
expect(countAt15m).toBeGreaterThan(0)
expect(countAt15m).toBeLessThan(40)

// past the cap the chain is not rescheduled — no further calls, ever.
// 10 more minutes at the 60s floor → ~10 further polls, NOT zero (the old
// cap stranded the user here) and NOT a 5s-cadence flood.
await act(async () => {
await jest.advanceTimersByTimeAsync(30 * 60_000)
await jest.advanceTimersByTimeAsync(10 * 60_000)
})
expect(mockInitiate).toHaveBeenCalledTimes(countAtCap)
const delta = mockInitiate.mock.calls.length - countAt15m
expect(delta).toBeGreaterThanOrEqual(8)
expect(delta).toBeLessThanOrEqual(12)
})

it('a status that goes APPROVED after a long wait still surfaces (no strand)', async () => {
const onKycSuccess = jest.fn()
const view = renderHook(() => useSumsubKycFlow({ onKycSuccess }))
act(() => {
view.result.current.handleSdkComplete()
})

// 20 minutes of "still pending" — well past the old 15-min cap.
await act(async () => {
await jest.advanceTimersByTimeAsync(20 * 60_000)
})
// Backend finally approves; the next poll picks it up.
mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'APPROVED' } })
await act(async () => {
await jest.advanceTimersByTimeAsync(60_000)
})
expect(onKycSuccess).toHaveBeenCalled()
})

it('cancels the pending timer when the modal closes', async () => {
Expand Down
20 changes: 11 additions & 9 deletions src/hooks/useSumsubKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ const KYC_POLL_SCHEDULE: ReadonlyArray<{ untilMs: number; delayMs: number }> = [
{ untilMs: 120_000, delayMs: 10_000 },
{ untilMs: 180_000, delayMs: 20_000 },
]
// After the escalation schedule the poll settles at this steady cadence for as
// long as the modal stays open. It does NOT stop: a missed websocket event
// (laptop sleep, mobile background, network switch) can land at any time during
// a long manual review, and a hard stop would strand the user on "Almost there"
// forever with onKycSuccess never firing. The 60s floor plus the backend's own
// self-recovery cooldown (which short-circuits repeat submissions server-side)
// keeps the steady poll cheap — nothing like the fixed-5s battering ram this
// schedule replaced.
const KYC_POLL_MAX_DELAY_MS = 60_000
// Stop polling entirely after this long per modal-open. The modal stays in its
// long-running "Almost there" state and the websocket remains the only signal;
// re-opening the modal restarts polling fresh.
const KYC_POLL_CAP_MS = 15 * 60_000

const getKycPollDelayMs = (elapsedMs: number): number => {
for (const { untilMs, delayMs } of KYC_POLL_SCHEDULE) {
Expand Down Expand Up @@ -149,8 +153,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
// (KYC_POLL_SCHEDULE) so the flow can transition even if the websocket event
// never arrives — without hammering the mutating initiate endpoint. A
// self-rescheduling setTimeout chain (rather than a fixed setInterval) lets
// the delay grow as the modal stays open; polling stops entirely once the
// ~15 min cap is reached, leaving the modal in its long-running state.
// the delay grow as the modal stays open, settling at a steady 60s cadence —
// it keeps polling for the whole modal-open lifetime so a late/missed
// websocket event is always eventually recovered.
useEffect(() => {
if (!isVerificationProgressModalOpen) return

Expand All @@ -175,9 +180,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:

const scheduleNext = () => {
const elapsed = Date.now() - startedAt
// active-polling cap reached — stop rescheduling; the websocket is the
// only signal from here, and re-opening the modal restarts polling.
if (elapsed >= KYC_POLL_CAP_MS) return
timeoutId = setTimeout(async () => {
await pollStatus()
// the modal may have closed (cleanup ran) while the poll was in
Expand Down
Loading