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
135 changes: 135 additions & 0 deletions apps/api/src/__tests__/IdentityVerificationSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
IdentityVerificationSession,
Person,
} from '@zoneless/shared-types';
import {
IsBusinessAccount,
ResolvedIdentityProvider,
SelectIdentityWorkflow,
} from '../modules/identity/ResolveIdentityProvider';
import { IDENTITY_REQUIREMENT_FIELDS } from '@zoneless/shared-schemas';
import {
CreateMockDatabase,
Expand Down Expand Up @@ -71,6 +76,69 @@ describe('IdentitySettingsCrypto', () => {
expect(redacted.settings?.identity?.didit?.api_key_set).toBe(true);
expect(redacted.settings?.identity?.didit?.webhook_secret_set).toBe(true);
expect(redacted.settings?.identity?.didit?.workflow_id).toBe('wf_123');
expect(encrypted?.didit?.kyb_workflow_id).toBeUndefined();
});

it('persists kyb_workflow_id without encrypting it', () => {
const encrypted = EncryptIdentitySettings({
provider: 'didit',
didit: {
api_key: 'didit_live_key',
workflow_id: 'wf_kyc',
kyb_workflow_id: 'wf_kyb',
webhook_secret: 'whsec_abc',
},
});

expect(encrypted?.didit?.workflow_id).toBe('wf_kyc');
expect(encrypted?.didit?.kyb_workflow_id).toBe('wf_kyb');
});
});

describe('SelectIdentityWorkflow', () => {
function BuildResolved(
kybWorkflowId: string | null = 'wf_kyb'
): ResolvedIdentityProvider {
return {
provider: {} as never,
apiKey: 'key',
workflowId: 'wf_kyc',
kybWorkflowId,
webhookSecret: null,
};
}

it('uses the KYC workflow for individuals', () => {
expect(
SelectIdentityWorkflow(BuildResolved(), {
business_type: 'individual',
} as Account)
).toEqual({ workflowId: 'wf_kyc', isKyb: false });
});

it('uses the KYB workflow for companies when configured', () => {
expect(
SelectIdentityWorkflow(BuildResolved(), {
business_type: 'company',
} as Account)
).toEqual({ workflowId: 'wf_kyb', isKyb: true });
});

it('falls back to the KYC workflow when kyb_workflow_id is unset', () => {
expect(
SelectIdentityWorkflow(BuildResolved(null), {
business_type: 'company',
} as Account)
).toEqual({ workflowId: 'wf_kyc', isKyb: false });
});

it('treats non_profit and government_entity as business accounts', () => {
expect(IsBusinessAccount({ business_type: 'non_profit' })).toBe(true);
expect(IsBusinessAccount({ business_type: 'government_entity' })).toBe(
true
);
expect(IsBusinessAccount({ business_type: 'individual' })).toBe(false);
expect(IsBusinessAccount(null)).toBe(false);
});
});

Expand Down Expand Up @@ -359,6 +427,71 @@ describe('IdentityVerificationSessionModule', () => {
}),
})
);
const createBody = JSON.parse(
(global.fetch as jest.Mock).mock.calls[0][1].body
);
expect(createBody.workflow_id).toBe('wf_test');
expect(createBody.expected_details).toBeUndefined();
});

it('uses the KYB workflow and expected_details for company accounts', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: EncryptIdentitySettings({
provider: 'didit',
didit: {
api_key: 'didit_key',
workflow_id: 'wf_test',
kyb_workflow_id: 'wf_kyb',
webhook_secret: 'whsec_test',
},
}),
};
storedAccounts.set(platformId, platform);

const connected = storedAccounts.get(connectedId)!;
connected.business_type = 'company';
connected.business_profile = {
name: "Ben's Business",
mcc: null,
product_description: null,
support_email: null,
support_phone: null,
support_url: null,
url: null,
};
storedAccounts.set(connectedId, connected);

await sessionModule.Create(platformId, {
type: 'document',
related_account: connectedId,
});

const createBody = JSON.parse(
(global.fetch as jest.Mock).mock.calls[0][1].body
);
expect(createBody.workflow_id).toBe('wf_kyb');
expect(createBody.expected_details).toEqual({
company_name: "Ben's Business",
registry_country: 'US',
});
});

it('falls back to the KYC workflow for companies without kyb_workflow_id', async () => {
const connected = storedAccounts.get(connectedId)!;
connected.business_type = 'company';
storedAccounts.set(connectedId, connected);

await sessionModule.Create(platformId, {
type: 'document',
related_account: connectedId,
});

const createBody = JSON.parse(
(global.fetch as jest.Mock).mock.calls[0][1].body
);
expect(createBody.workflow_id).toBe('wf_test');
expect(createBody.expected_details).toBeUndefined();
});

it('cancels a session and sets person back to unverified', async () => {
Expand Down Expand Up @@ -941,6 +1074,7 @@ describe('AccountModule identity settings merge', () => {
didit: {
api_key: 'plain_key',
workflow_id: 'wf_1',
kyb_workflow_id: 'wf_kyb_1',
webhook_secret: 'plain_secret',
},
rules: { payout_volume_threshold_cents: 1000 },
Expand All @@ -950,6 +1084,7 @@ describe('AccountModule identity settings merge', () => {

expect(updated.settings?.identity?.didit?.api_key).not.toBe('plain_key');
expect(updated.settings?.identity?.didit?.workflow_id).toBe('wf_1');
expect(updated.settings?.identity?.didit?.kyb_workflow_id).toBe('wf_kyb_1');
expect(
DecryptIdentitySecret(updated.settings?.identity?.didit?.api_key)
).toBe('plain_key');
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/modules/IdentityVerificationSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { AccountModule } from './Account';
import { PersonModule } from './Person';
import { IdentityLiteModule } from './identity/IdentityLite';
import { GetAppConfig } from './AppConfig';
import { ResolveIdentityProvider } from './identity/ResolveIdentityProvider';
import {
ResolveIdentityProvider,
SelectIdentityWorkflow,
} from './identity/ResolveIdentityProvider';
import { DecryptIdentitySecret } from './identity/IdentitySettingsCrypto';
import { GenerateId } from '../utils/IdGenerator';
import { Now } from '../utils/Timestamp';
Expand Down Expand Up @@ -95,17 +98,24 @@ export class IdentityVerificationSessionModule {
);

const resolved = ResolveIdentityProvider(platformAccount);
const selected = SelectIdentityWorkflow(resolved, relatedAccount);
const sessionId = GenerateId('vs_z');

const providerSession = await resolved.provider.CreateSession(
resolved.apiKey,
{
workflowId: resolved.workflowId,
workflowId: selected.workflowId,
vendorData: sessionId,
callbackUrl: validated.return_url,
email: validated.provided_details?.email,
phone: validated.provided_details?.phone,
metadata: validated.metadata,
expectedDetails: selected.isKyb
? {
company_name: relatedAccount.business_profile?.name ?? undefined,
registry_country: relatedAccount.country || undefined,
}
: undefined,
}
);

Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/modules/identity/DiditProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export class DiditProvider implements IdentityVerificationProvider {
body.metadata = input.metadata;
}

const expectedDetails = CompactExpectedDetails(input.expectedDetails);
if (expectedDetails) {
body.expected_details = expectedDetails;
}

const response = await fetch(`${DIDIT_API_BASE}/session/`, {
method: 'POST',
headers: {
Expand Down Expand Up @@ -274,4 +279,16 @@ function ShortenFloats(obj: unknown): unknown {
return obj;
}

function CompactExpectedDetails(
details: ProviderCreateSessionInput['expectedDetails']
): Record<string, string> | null {
if (!details) return null;
const compacted: Record<string, string> = {};
const companyName = details.company_name?.trim();
const registryCountry = details.registry_country?.trim();
if (companyName) compacted.company_name = companyName;
if (registryCountry) compacted.registry_country = registryCountry;
return Object.keys(compacted).length > 0 ? compacted : null;
}

export const diditProvider = new DiditProvider();
7 changes: 7 additions & 0 deletions apps/api/src/modules/identity/IdentityVerificationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
IdentityVerificationSessionType,
} from '@zoneless/shared-types';

export interface ProviderExpectedDetails {
company_name?: string;
registry_country?: string;
}

export interface ProviderCreateSessionInput {
/** Provider workflow / flow ID */
workflowId: string;
Expand All @@ -23,6 +28,8 @@ export interface ProviderCreateSessionInput {
email?: string | null;
phone?: string | null;
metadata?: Record<string, string>;
/** Prefill for KYB company registry search */
expectedDetails?: ProviderExpectedDetails;
}

export interface ProviderSession {
Expand Down
41 changes: 40 additions & 1 deletion apps/api/src/modules/identity/ResolveIdentityProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
* @module ResolveIdentityProvider
*/

import { Account as AccountType } from '@zoneless/shared-types';
import {
Account as AccountType,
AccountBusinessType,
} from '@zoneless/shared-types';
import { AppError } from '../../utils/AppError';
import { DecryptIdentitySecret } from './IdentitySettingsCrypto';
import { diditProvider } from './DiditProvider';
Expand All @@ -13,10 +16,18 @@ import { IdentityVerificationProvider } from './IdentityVerificationProvider';
export interface ResolvedIdentityProvider {
provider: IdentityVerificationProvider;
apiKey: string;
/** Individual / KYC workflow */
workflowId: string;
/** Company / KYB workflow when configured */
kybWorkflowId: string | null;
webhookSecret: string | null;
}

export interface SelectedIdentityWorkflow {
workflowId: string;
isKyb: boolean;
}

/**
* Resolve BYO identity-provider credentials from a platform account's
* settings.identity (currently Didit).
Expand All @@ -37,6 +48,7 @@ export function ResolveIdentityProvider(

const apiKey = DecryptIdentitySecret(identity?.didit?.api_key);
const workflowId = identity?.didit?.workflow_id?.trim() || null;
const kybWorkflowId = identity?.didit?.kyb_workflow_id?.trim() || null;
const webhookSecret = DecryptIdentitySecret(identity?.didit?.webhook_secret);

if (!apiKey || !workflowId) {
Expand All @@ -51,10 +63,37 @@ export function ResolveIdentityProvider(
provider: diditProvider,
apiKey,
workflowId,
kybWorkflowId,
webhookSecret,
};
}

/**
* True when the account represents a legal entity rather than a person.
*/
export function IsBusinessAccount(
account: { business_type?: AccountBusinessType | null } | null | undefined
): boolean {
const type = account?.business_type;
return (
type === 'company' || type === 'non_profit' || type === 'government_entity'
);
}

/**
* Choose the Didit workflow for a connected account.
* Business accounts use kyb_workflow_id when set; otherwise KYC workflow_id.
*/
export function SelectIdentityWorkflow(
resolved: ResolvedIdentityProvider,
connectedAccount: AccountType
): SelectedIdentityWorkflow {
if (IsBusinessAccount(connectedAccount) && resolved.kybWorkflowId) {
return { workflowId: resolved.kybWorkflowId, isKyb: true };
}
return { workflowId: resolved.workflowId, isKyb: false };
}

/**
* True when the platform has identity-provider credentials needed to run IDV.
*/
Expand Down
Loading
Loading