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
140 changes: 140 additions & 0 deletions apps/api/src/__tests__/IdentityVerificationSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const body: Record<string, unknown> = {
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',
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
});

Expand Down
40 changes: 38 additions & 2 deletions apps/api/src/modules/IdentityVerificationSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
CreateIdentityVerificationSessionInput,
CreateIdentityVerificationSessionSchema,
IDENTITY_ERROR_CODES,
IsRejectedAccountReason,
UpdateIdentityVerificationSessionInput,
UpdateIdentityVerificationSessionSchema,
} from '@zoneless/shared-schemas';
Expand Down Expand Up @@ -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<void> {
if (session.status === 'canceled' || session.redaction) {
return;
Expand Down Expand Up @@ -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') {
Expand All @@ -540,6 +565,17 @@ export class IdentityVerificationSessionModule {
}
}

private async RejectAccountOnDiditDecline(accountId: string): Promise<void> {
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 {
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/modules/identity/DiditProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
signatureHeader: string,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/identityWebhooks.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ router.post(
sessionId: body.session_id,
status: body.status,
webhookType: body.webhook_type,
trigger: body.trigger,
});

await sessionModule.HandleDiditWebhook(body, {
Expand Down
Loading