From f9a351de32a3f5daa49620878e639b61c2f27c5a Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Tue, 8 Sep 2026 18:55:21 +0100 Subject: [PATCH] feat: didit decline rejects instead of restricts --- .../IdentityVerificationSession.spec.ts | 140 ++++++++++++++++++ .../modules/IdentityVerificationSession.ts | 40 ++++- .../api/src/modules/identity/DiditProvider.ts | 30 ++++ .../api/src/routes/identityWebhooks.routes.ts | 1 + 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts index 44003f4..2200d9f 100644 --- a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts +++ b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts @@ -37,6 +37,42 @@ jest.mock('../utils/Timestamp', () => ({ const TEST_ENCRYPTION_KEY = 'a'.repeat(64); +async function HandleSignedDiditWebhook( + sessionModule: IdentityVerificationSessionModule, + providerSessionId: string | null, + overrides: { status: string; trigger?: string } +): Promise { + const body: Record = { + timestamp: GetFixedTimestamp(), + session_id: providerSessionId, + status: overrides.status, + webhook_type: 'status.updated', + }; + if (overrides.trigger) { + body.trigger = overrides.trigger; + } + const canonical = [ + body.timestamp, + body.session_id, + body.status, + body.webhook_type, + ].join(':'); + const signature = createHmac('sha256', 'whsec_test') + .update(canonical) + .digest('hex'); + + const realNow = Date.now; + Date.now = () => GetFixedTimestamp() * 1000; + try { + await sessionModule.HandleDiditWebhook(body, { + signatureSimple: signature, + timestamp: String(GetFixedTimestamp()), + }); + } finally { + Date.now = realNow; + } +} + jest.mock('../modules/AppConfig', () => ({ GetAppConfig: jest.fn(() => ({ dashboardUrl: 'http://localhost:4200', @@ -152,6 +188,26 @@ describe('DiditProvider', () => { expect(provider.MapStatus('Not Started')).toBe('requires_input'); }); + it('treats console reviewer triggers as manual declines', () => { + expect(provider.IsManualDeclineTrigger('manual_review')).toBe(true); + expect(provider.IsManualDeclineTrigger('manual_step_update')).toBe(true); + expect(provider.IsManualDeclineTrigger('ongoing_monitoring')).toBe(false); + expect(provider.IsManualDeclineTrigger(undefined)).toBe(false); + expect(provider.IsManualDeclineTrigger(null)).toBe(false); + }); + + it('treats an already-verified session decline as manual', () => { + expect( + provider.IsManualDiditDecline({ previousSessionStatus: 'verified' }) + ).toBe(true); + expect( + provider.IsManualDiditDecline({ previousSessionStatus: 'processing' }) + ).toBe(false); + expect( + provider.IsManualDiditDecline({ previousSessionStatus: 'requires_input' }) + ).toBe(false); + }); + it('verifies X-Signature-Simple webhooks', () => { const secret = 'test_webhook_secret'; const body = { @@ -585,6 +641,90 @@ describe('IdentityVerificationSessionModule', () => { expect(storedPersons.get(personId)?.verification?.details_code).toBe( 'verification_failed' ); + expect(storedAccounts.get(connectedId)?.requirements?.disabled_reason).toBe( + null + ); + expect(storedAccounts.get(connectedId)?.payouts_enabled).toBe(true); + expect(storedAccounts.get(connectedId)?.charges_enabled).toBe(true); + }); + + it('rejects the account when an approved session is later declined', async () => { + const session = await sessionModule.Create(platformId, { + type: 'document', + related_account: connectedId, + }); + + await HandleSignedDiditWebhook(sessionModule, session.provider_session_id, { + status: 'Approved', + }); + expect(storedSessions.get(session.id)?.status).toBe('verified'); + + await HandleSignedDiditWebhook(sessionModule, session.provider_session_id, { + status: 'Declined', + }); + + expect(storedAccounts.get(connectedId)?.requirements?.disabled_reason).toBe( + 'rejected.fraud' + ); + expect(storedAccounts.get(connectedId)?.payouts_enabled).toBe(false); + }); + + it('rejects the account on a Didit dashboard decline', async () => { + const session = await sessionModule.Create(platformId, { + type: 'document', + related_account: connectedId, + }); + + await HandleSignedDiditWebhook(sessionModule, session.provider_session_id, { + status: 'Declined', + trigger: 'manual_review', + }); + + expect(storedSessions.get(session.id)?.status).toBe('requires_input'); + expect(storedPersons.get(personId)?.verification?.status).toBe( + 'unverified' + ); + expect(storedAccounts.get(connectedId)?.requirements?.disabled_reason).toBe( + 'rejected.fraud' + ); + expect(storedAccounts.get(connectedId)?.payouts_enabled).toBe(false); + expect(storedAccounts.get(connectedId)?.charges_enabled).toBe(false); + }); + + it('does not reject the account on abandoned Didit sessions', async () => { + const session = await sessionModule.Create(platformId, { + type: 'document', + related_account: connectedId, + }); + + await HandleSignedDiditWebhook(sessionModule, session.provider_session_id, { + status: 'Abandoned', + }); + + expect(storedSessions.get(session.id)?.status).toBe('requires_input'); + expect(storedAccounts.get(connectedId)?.requirements?.disabled_reason).toBe( + null + ); + expect(storedAccounts.get(connectedId)?.payouts_enabled).toBe(true); + expect(storedAccounts.get(connectedId)?.charges_enabled).toBe(true); + }); + + it('does not reject the account on expired Didit sessions', async () => { + const session = await sessionModule.Create(platformId, { + type: 'document', + related_account: connectedId, + }); + + await HandleSignedDiditWebhook(sessionModule, session.provider_session_id, { + status: 'Expired', + }); + + expect(storedSessions.get(session.id)?.status).toBe('requires_input'); + expect(storedAccounts.get(connectedId)?.requirements?.disabled_reason).toBe( + null + ); + expect(storedAccounts.get(connectedId)?.payouts_enabled).toBe(true); + expect(storedAccounts.get(connectedId)?.charges_enabled).toBe(true); }); }); diff --git a/apps/api/src/modules/IdentityVerificationSession.ts b/apps/api/src/modules/IdentityVerificationSession.ts index 148fbb9..8d51f15 100644 --- a/apps/api/src/modules/IdentityVerificationSession.ts +++ b/apps/api/src/modules/IdentityVerificationSession.ts @@ -38,6 +38,7 @@ import { CreateIdentityVerificationSessionInput, CreateIdentityVerificationSessionSchema, IDENTITY_ERROR_CODES, + IsRejectedAccountReason, UpdateIdentityVerificationSessionInput, UpdateIdentityVerificationSessionSchema, } from '@zoneless/shared-schemas'; @@ -427,12 +428,16 @@ export class IdentityVerificationSessionModule { const providerStatus = typeof body.status === 'string' ? body.status : 'Not Started'; - await this.ApplyProviderStatus(session, providerStatus); + await this.ApplyProviderStatus(session, providerStatus, { + trigger: body.trigger, + decision: body.decision, + }); } private async ApplyProviderStatus( session: IdentityVerificationSessionType, - providerStatus: string + providerStatus: string, + webhook?: { trigger?: unknown; decision?: unknown } ): Promise { if (session.status === 'canceled' || session.redaction) { return; @@ -520,6 +525,26 @@ export class IdentityVerificationSessionModule { } } + if ( + mapped === 'requires_input' && + providerStatus.toLowerCase() === 'declined' + ) { + const isManualDecline = diditProvider.IsManualDiditDecline({ + trigger: webhook?.trigger, + previousSessionStatus: previousStatus, + }); + Logger.info('Didit decline received', { + sessionId: session.id, + accountId: session.related_account, + trigger: webhook?.trigger ?? null, + previousSessionStatus: previousStatus, + isManualDecline, + }); + if (isManualDecline) { + await this.RejectAccountOnDiditDecline(session.related_account); + } + } + await this.identityLiteModule.EvaluateAndApply(session.related_account); if (mapped === 'verified') { @@ -540,6 +565,17 @@ export class IdentityVerificationSessionModule { } } + private async RejectAccountOnDiditDecline(accountId: string): Promise { + const account = await this.accountModule.GetAccount(accountId); + if (!account) { + return; + } + if (IsRejectedAccountReason(account.requirements?.disabled_reason)) { + return; + } + await this.accountModule.RejectAccount(accountId, { reason: 'fraud' }); + } + private EventTypeForStatus( status: IdentityVerificationSessionStatus ): EventType | null { diff --git a/apps/api/src/modules/identity/DiditProvider.ts b/apps/api/src/modules/identity/DiditProvider.ts index 1f05798..2ed2111 100644 --- a/apps/api/src/modules/identity/DiditProvider.ts +++ b/apps/api/src/modules/identity/DiditProvider.ts @@ -200,6 +200,36 @@ export class DiditProvider implements IdentityVerificationProvider { } } + /** + * Console reviewer actions from the Didit dashboard. + * Automatic onboarding declines omit `trigger`. + * @see https://docs.didit.me/integration/webhooks + */ + IsManualDeclineTrigger(trigger: unknown): boolean { + return trigger === 'manual_review' || trigger === 'manual_step_update'; + } + + /** + * Hard-reject only for a human Didit decline, not an automatic IDV fail. + * + * Documented console signal: webhook `trigger` of `manual_review` / + * `manual_step_update`. Didit marks `trigger` optional and has omitted it + * on real console declines, so we also treat a session that was already + * verified (Approved → Declined) as a console override. + * + * Do not use `decision.reviews`: that feed includes SYSTEM / automatic + * STATUS_UPDATED rows, not only human reviewers. + */ + IsManualDiditDecline(input: { + trigger?: unknown; + previousSessionStatus?: string | null; + }): boolean { + if (this.IsManualDeclineTrigger(input.trigger)) { + return true; + } + return input.previousSessionStatus === 'verified'; + } + private VerifySignatureV2( body: Record, signatureHeader: string, diff --git a/apps/api/src/routes/identityWebhooks.routes.ts b/apps/api/src/routes/identityWebhooks.routes.ts index 0af063f..c1385d9 100644 --- a/apps/api/src/routes/identityWebhooks.routes.ts +++ b/apps/api/src/routes/identityWebhooks.routes.ts @@ -25,6 +25,7 @@ router.post( sessionId: body.session_id, status: body.status, webhookType: body.webhook_type, + trigger: body.trigger, }); await sessionModule.HandleDiditWebhook(body, {