From bfdec3cd388bf82bd5efe0d1d5a30351cd39873c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 17:47:37 -0700 Subject: [PATCH] =?UTF-8?q?fix(kyc):=20don't=20strand=20users=20=E2=80=94?= =?UTF-8?q?=20verification=20poll=20holds=20a=20steady=20cadence,=20never?= =?UTF-8?q?=20hard-stops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15-min KYC_POLL_CAP_MS stopped the fallback poll entirely, so a websocket event missed during a long manual review (laptop sleep, mobile background, network switch) left the user on 'Almost there' forever with onKycSuccess never firing. The poll now settles at the 60s floor and keeps going for the whole modal-open lifetime; the backend self-recovery cooldown bounds the per-call cost, so it's nothing like the fixed-5s battering ram the schedule replaced. Regression tests: keeps polling past 15 min (bounded ~10 calls in the following 10 min, not zero, not a flood) and a late APPROVED still fires onKycSuccess. --- src/hooks/__tests__/useSumsubKycFlow.test.ts | 45 +++++++++++++++----- src/hooks/useSumsubKycFlow.ts | 20 +++++---- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index c1deedc85..f97c444a7 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -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() @@ -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 () => { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 99e2e89a2..d16af6deb 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -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) { @@ -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 @@ -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