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
149 changes: 149 additions & 0 deletions apps/api/src/__tests__/IdentityVerificationSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,155 @@ describe('Identity volume threshold gating', () => {
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('skips document IDV when Didit is not configured', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: {
provider: 'didit',
didit: { workflow_id: null, api_key: null },
rules: { payout_volume_threshold_cents: 0 },
},
};
storedAccounts.set(platformId, platform);
mockDb.Aggregate.mockResolvedValue([{ gross: 0 }]);

const evaluation = await module.EvaluateAndApply(connectedId);

expect(evaluation.eventuallyDue).not.toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
expect(evaluation.currentlyDue).not.toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('applies country override $0 for PK immediately', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: EncryptIdentitySettings({
provider: 'didit',
didit: { api_key: 'k', workflow_id: 'wf', webhook_secret: 's' },
rules: {
payout_volume_threshold_cents: 10_000,
country_thresholds: [
{ countries: ['PK', 'ID'], payout_volume_threshold_cents: 0 },
{ countries: ['IN'], payout_volume_threshold_cents: 5_000 },
],
},
}),
};
storedAccounts.set(platformId, platform);

const connected = storedAccounts.get(connectedId)!;
connected.country = 'PK';
storedAccounts.set(connectedId, connected);
mockDb.Aggregate.mockResolvedValue([{ gross: 0 }]);

const evaluation = await module.EvaluateAndApply(connectedId);

expect(evaluation.blocking).toBe(true);
expect(evaluation.currentlyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('uses India override $50 below and at threshold', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: EncryptIdentitySettings({
provider: 'didit',
didit: { api_key: 'k', workflow_id: 'wf', webhook_secret: 's' },
rules: {
payout_volume_threshold_cents: 10_000,
country_thresholds: [
{ countries: ['PK', 'ID'], payout_volume_threshold_cents: 0 },
{ countries: ['IN'], payout_volume_threshold_cents: 5_000 },
],
},
}),
};
storedAccounts.set(platformId, platform);

const connected = storedAccounts.get(connectedId)!;
connected.country = 'IN';
storedAccounts.set(connectedId, connected);

mockDb.Aggregate.mockResolvedValue([{ gross: 4_999 }]);
let evaluation = await module.EvaluateAndApply(connectedId);
expect(evaluation.blocking).toBe(false);
expect(evaluation.eventuallyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);

mockDb.Aggregate.mockResolvedValue([{ gross: 5_000 }]);
evaluation = await module.EvaluateAndApply(connectedId);
expect(evaluation.blocking).toBe(true);
expect(evaluation.currentlyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('falls back to default $100 for unmatched countries', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: EncryptIdentitySettings({
provider: 'didit',
didit: { api_key: 'k', workflow_id: 'wf', webhook_secret: 's' },
rules: {
payout_volume_threshold_cents: 10_000,
country_thresholds: [
{ countries: ['PK', 'ID'], payout_volume_threshold_cents: 0 },
{ countries: ['IN'], payout_volume_threshold_cents: 5_000 },
],
},
}),
};
storedAccounts.set(platformId, platform);

const connected = storedAccounts.get(connectedId)!;
connected.country = 'US';
storedAccounts.set(connectedId, connected);

mockDb.Aggregate.mockResolvedValue([{ gross: 9_999 }]);
let evaluation = await module.EvaluateAndApply(connectedId);
expect(evaluation.blocking).toBe(false);
expect(evaluation.eventuallyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);

mockDb.Aggregate.mockResolvedValue([{ gross: 10_000 }]);
evaluation = await module.EvaluateAndApply(connectedId);
expect(evaluation.blocking).toBe(true);
expect(evaluation.currentlyDue).toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});

it('skips document IDV when no threshold rules are set', async () => {
const platform = storedAccounts.get(platformId)!;
platform.settings = {
identity: EncryptIdentitySettings({
provider: 'didit',
didit: { api_key: 'k', workflow_id: 'wf', webhook_secret: 's' },
rules: {
payout_volume_threshold_cents: null,
country_thresholds: [],
},
}),
};
storedAccounts.set(platformId, platform);
mockDb.Aggregate.mockResolvedValue([{ gross: 1_000_000 }]);

const evaluation = await module.EvaluateAndApply(connectedId);

expect(evaluation.currentlyDue).not.toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
expect(evaluation.eventuallyDue).not.toContain(
IDENTITY_REQUIREMENT_FIELDS.verificationDocument
);
});
});

describe('AccountModule identity settings merge', () => {
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/modules/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
REJECTED_DISABLED_REASONS,
ConnectedAccountStatusFilter,
IsRejectedAccountReason,
HasPayoutVolumeThresholdRules,
} from '@zoneless/shared-schemas';
import {
ListHelper,
Expand All @@ -39,6 +40,7 @@ import {
FilterCondition,
} from '../utils/ListHelper';
import { EncryptIdentitySettings } from './identity/IdentitySettingsCrypto';
import { IsIdentityProviderConfigured } from './identity/ResolveIdentityProvider';

export class AccountModule {
private readonly db: Database;
Expand Down Expand Up @@ -319,6 +321,42 @@ export class AccountModule {
};
}

/**
* Threshold rules require a configured identity provider so accounts are not
* stuck with unverifiable document requirements.
*/
private AssertIdentityThresholdsAllowed(
mergedAccount: AccountType,
previousAccount: AccountType | null | undefined
): void {
const rules = mergedAccount.settings?.identity?.rules;
if (!HasPayoutVolumeThresholdRules(rules)) {
return;
}

const candidate: AccountType = {
...mergedAccount,
settings: {
...mergedAccount.settings,
identity: {
...mergedAccount.settings?.identity,
didit: {
...previousAccount?.settings?.identity?.didit,
...mergedAccount.settings?.identity?.didit,
},
},
},
};

if (!IsIdentityProviderConfigured(candidate)) {
throw new AppError(
'Configure an identity provider API key and workflow ID before setting volume thresholds.',
400,
'invalid_request_error'
);
}
}

async GetAccount(accountId: string): Promise<AccountType | null> {
return this.db.Get<AccountType>('Accounts', accountId);
}
Expand Down Expand Up @@ -498,6 +536,13 @@ export class AccountModule {
account?.settings || {},
input.settings
);
this.AssertIdentityThresholdsAllowed(
{
...(account || {}),
settings: result.settings,
} as AccountType,
account
);
}

if (input.tos_acceptance !== undefined) {
Expand Down
14 changes: 11 additions & 3 deletions apps/api/src/modules/identity/IdentityLite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ import {
IsRoleEmail,
GetFormBlockingIdentityRequirements,
NormalizeIpAddress,
ResolvePayoutVolumeThresholdCents,
} from '@zoneless/shared-schemas';
import { GetLifetimePaidPayoutVolumeCents } from './IdentityPayoutVolume';
import { IsIdentityProviderConfigured } from './ResolveIdentityProvider';

export interface IdentityLiteEvaluation {
currentlyDue: string[];
Expand Down Expand Up @@ -439,9 +441,15 @@ export class IdentityLiteModule {
? account
: await this.accountModule.GetAccount(platformId);

const threshold =
platform?.settings?.identity?.rules?.payout_volume_threshold_cents ??
null;
// Do not foreshadow / promote document IDV without a usable provider
if (!IsIdentityProviderConfigured(platform)) {
return;
}

const threshold = ResolvePayoutVolumeThresholdCents(
platform?.settings?.identity?.rules,
account.country
);

if (threshold === null || threshold === undefined) {
return;
Expand Down
19 changes: 17 additions & 2 deletions apps/api/src/modules/identity/ResolveIdentityProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export interface ResolvedIdentityProvider {
}

/**
* Resolve BYO Didit credentials from a platform account's settings.identity.
* Resolve BYO identity-provider credentials from a platform account's
* settings.identity (currently Didit).
*/
export function ResolveIdentityProvider(
platformAccount: AccountType
Expand All @@ -40,7 +41,7 @@ export function ResolveIdentityProvider(

if (!apiKey || !workflowId) {
throw new AppError(
'Identity verification is not configured. Set settings.identity.didit.api_key and workflow_id on the platform account.',
'Identity verification is not configured. Set settings.identity provider credentials (api_key and workflow_id) on the platform account.',
400,
'invalid_request_error'
);
Expand All @@ -53,3 +54,17 @@ export function ResolveIdentityProvider(
webhookSecret,
};
}

/**
* True when the platform has identity-provider credentials needed to run IDV.
*/
export function IsIdentityProviderConfigured(
platformAccount: AccountType | null | undefined
): boolean {
if (!platformAccount) return false;
const identity = platformAccount.settings?.identity;
if ((identity?.provider ?? 'didit') !== 'didit') return false;
const apiKey = DecryptIdentitySecret(identity?.didit?.api_key);
const workflowId = identity?.didit?.workflow_id?.trim();
return !!(apiKey && workflowId);
}
3 changes: 1 addition & 2 deletions apps/api/src/routes/config.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ import { AsyncHandler } from '../utils/AsyncHandler';
import { AppError } from '../utils/AppError';
import { ERRORS } from '../utils/Errors';
import { VerifyToken } from '../utils/Token';
import { GetJwtSecret } from '../modules/AppConfig';
import { GetJwtSecret, GetAppConfig } from '../modules/AppConfig';
import { SolanaExplorerUrl } from '../modules/chains/Solana';
import { GetAppConfig } from '../modules/AppConfig';

const router = express.Router();

Expand Down
31 changes: 18 additions & 13 deletions apps/web/src/app/data/services/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ApiService } from '../../core/services/api.service';
import { Account, LoginLink } from '@zoneless/shared-types';
import {
CreateAccountInput,
FormatPayoutVolumeThresholdCents,
UpdateAccountInput,
} from '@zoneless/shared-schemas';
import { SettingsCardRow } from '../../shared';
Expand Down Expand Up @@ -228,38 +229,42 @@ export class AccountService {
GetIdentitySettingsCardRows(account: Account | null): SettingsCardRow[] {
if (!account) return [];

const didit = account.settings?.identity?.didit;
const providerSettings = account.settings?.identity?.didit;
const rules = account.settings?.identity?.rules;
const thresholdCents = rules?.payout_volume_threshold_cents;

let thresholdLabel = 'Disabled';
if (thresholdCents != null && thresholdCents >= 0) {
thresholdLabel = `$${(thresholdCents / 100).toLocaleString('en-US', {
maximumFractionDigits: 2,
})}`;
}
const thresholdLabel =
FormatPayoutVolumeThresholdCents(rules?.payout_volume_threshold_cents) ??
'Disabled';
const overrideCount = rules?.country_thresholds?.length ?? 0;

return [
{
label: 'API key',
value: didit?.api_key_set ? 'Configured' : 'Not set',
value: providerSettings?.api_key_set ? 'Configured' : 'Not set',
type: 'text',
},
{
label: 'Workflow ID',
value: didit?.workflow_id?.trim() || '—',
value: providerSettings?.workflow_id?.trim() || '—',
type: 'text',
},
{
label: 'Webhook secret',
value: didit?.webhook_secret_set ? 'Configured' : 'Not set',
value: providerSettings?.webhook_secret_set ? 'Configured' : 'Not set',
type: 'text',
},
{
label: 'Payout volume threshold',
label: 'Default payout volume threshold',
value: thresholdLabel,
type: 'text',
},
{
label: 'Country overrides',
value:
overrideCount > 0
? `${overrideCount} override${overrideCount === 1 ? '' : 's'}`
: 'None',
type: 'text',
},
];
}
}
Loading
Loading