diff --git a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts index 12cf13d..968775a 100644 --- a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts +++ b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts @@ -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', () => { diff --git a/apps/api/src/modules/Account.ts b/apps/api/src/modules/Account.ts index 7448d74..47ce977 100644 --- a/apps/api/src/modules/Account.ts +++ b/apps/api/src/modules/Account.ts @@ -31,6 +31,7 @@ import { REJECTED_DISABLED_REASONS, ConnectedAccountStatusFilter, IsRejectedAccountReason, + HasPayoutVolumeThresholdRules, } from '@zoneless/shared-schemas'; import { ListHelper, @@ -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; @@ -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 { return this.db.Get('Accounts', accountId); } @@ -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) { diff --git a/apps/api/src/modules/identity/IdentityLite.ts b/apps/api/src/modules/identity/IdentityLite.ts index 737af74..f078974 100644 --- a/apps/api/src/modules/identity/IdentityLite.ts +++ b/apps/api/src/modules/identity/IdentityLite.ts @@ -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[]; @@ -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; diff --git a/apps/api/src/modules/identity/ResolveIdentityProvider.ts b/apps/api/src/modules/identity/ResolveIdentityProvider.ts index 76c4d15..04a6809 100644 --- a/apps/api/src/modules/identity/ResolveIdentityProvider.ts +++ b/apps/api/src/modules/identity/ResolveIdentityProvider.ts @@ -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 @@ -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' ); @@ -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); +} diff --git a/apps/api/src/routes/config.routes.ts b/apps/api/src/routes/config.routes.ts index 8824c1b..3661e07 100644 --- a/apps/api/src/routes/config.routes.ts +++ b/apps/api/src/routes/config.routes.ts @@ -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(); diff --git a/apps/web/src/app/data/services/account.service.ts b/apps/web/src/app/data/services/account.service.ts index f0fdb2f..bc505af 100644 --- a/apps/web/src/app/data/services/account.service.ts +++ b/apps/web/src/app/data/services/account.service.ts @@ -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'; @@ -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', + }, ]; } } diff --git a/apps/web/src/app/features/account/connected-accounts/util/identity-requirements.ts b/apps/web/src/app/features/account/connected-accounts/util/identity-requirements.ts index b1b1780..d9aba4a 100644 --- a/apps/web/src/app/features/account/connected-accounts/util/identity-requirements.ts +++ b/apps/web/src/app/features/account/connected-accounts/util/identity-requirements.ts @@ -1,5 +1,9 @@ import type { Account } from '@zoneless/shared-types'; -import { IDENTITY_REQUIREMENT_FIELDS } from '@zoneless/shared-schemas'; +import { + FormatPayoutVolumeThresholdCents, + IDENTITY_REQUIREMENT_FIELDS, + ResolvePayoutVolumeThresholdCents, +} from '@zoneless/shared-schemas'; export const VERIFICATION_DOCUMENT_FIELD = IDENTITY_REQUIREMENT_FIELDS.verificationDocument; @@ -71,15 +75,16 @@ export function GetIdentityDocumentRequirementState( } /** - * Format a payout volume threshold in cents as a USD display string. + * Resolved payout-volume threshold for a connected account from platform rules. */ -function FormatPayoutVolumeThreshold( - thresholdCents: number | null | undefined -): string | null { - if (thresholdCents == null || thresholdCents < 0) return null; - return `$${(thresholdCents / 100).toLocaleString('en-US', { - maximumFractionDigits: 2, - })}`; +export function ResolveAccountPayoutVolumeThresholdCents( + connectedAccount: Account, + platformAccount: Account | null | undefined +): number | null { + return ResolvePayoutVolumeThresholdCents( + platformAccount?.settings?.identity?.rules, + connectedAccount.country + ); } /** @@ -106,7 +111,7 @@ export function GetIdentityDocumentActionSubtitle( thresholdCents?: number | null ): string { const state = GetIdentityDocumentRequirementState(account); - const thresholdLabel = FormatPayoutVolumeThreshold(thresholdCents); + const thresholdLabel = FormatPayoutVolumeThresholdCents(thresholdCents); if (state === 'pending') { return 'Verification in progress • Impacts payouts'; @@ -131,7 +136,7 @@ export function GetIdentityDocumentImpactCopy( thresholdCents?: number | null ): string { const state = GetIdentityDocumentRequirementState(account); - const thresholdLabel = FormatPayoutVolumeThreshold(thresholdCents); + const thresholdLabel = FormatPayoutVolumeThresholdCents(thresholdCents); if (state === 'currently_due' || state === 'pending') { return account.payouts_enabled diff --git a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts index 83edda0..3b0ed86 100644 --- a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts +++ b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts @@ -55,6 +55,7 @@ import { GetIdentityDocumentRequirementState, GetIdentityDocumentTaskDescription, NeedsIdentityDocumentAction, + ResolveAccountPayoutVolumeThresholdCents, } from '../../util/identity-requirements'; type DetailTab = 'overview' | 'payments'; @@ -144,11 +145,14 @@ export class ConnectedAccountDetailViewComponent implements OnInit, OnDestroy { GetIdentityDocumentMissingLabel(this.identityDocumentState()) ); - readonly payoutVolumeThresholdCents = computed( - () => - this.accountService.account()?.settings?.identity?.rules - ?.payout_volume_threshold_cents ?? null - ); + readonly payoutVolumeThresholdCents = computed(() => { + const account = this.account(); + if (!account) return null; + return ResolveAccountPayoutVolumeThresholdCents( + account, + this.accountService.account() + ); + }); readonly identityActionSubtitle = computed(() => { const account = this.account(); diff --git a/apps/web/src/app/features/account/settings/settings.component.ts b/apps/web/src/app/features/account/settings/settings.component.ts index 836084a..0755c32 100644 --- a/apps/web/src/app/features/account/settings/settings.component.ts +++ b/apps/web/src/app/features/account/settings/settings.component.ts @@ -98,7 +98,7 @@ export class SettingsComponent implements OnInit { editBusinessLoading: WritableSignal = signal(false); editBusinessShowErrors: WritableSignal = signal(false); - // Edit identity / Didit panel state + // Edit identity panel state editIdentityPanelOpen: WritableSignal = signal(false); editIdentityLoading: WritableSignal = signal(false); editIdentityShowErrors: WritableSignal = signal(false); diff --git a/apps/web/src/app/shared/forms/identity-settings-form/didit-webhook-url.ts b/apps/web/src/app/shared/forms/identity-settings-form/didit-webhook-url.ts new file mode 100644 index 0000000..60955fa --- /dev/null +++ b/apps/web/src/app/shared/forms/identity-settings-form/didit-webhook-url.ts @@ -0,0 +1,23 @@ +const DIDIT_WEBHOOK_PATH = '/v1/identity/webhooks/didit'; + +/** Zoneless cloud dashboard hosts → public Didit webhook URL */ +const ZONELESS_DASHBOARD_DIDIT_WEBHOOK_URLS: Record = { + 'dashboard.zoneless.com': `https://api.zoneless.com${DIDIT_WEBHOOK_PATH}`, + 'dashboard-test.zoneless.com': `https://api-test.zoneless.com${DIDIT_WEBHOOK_PATH}`, +}; + +/** + * Didit webhook destination for identity settings help copy. + * Zoneless cloud dashboards get the matching managed API URL; other hosts + * get an example absolute URL to adapt for their own API domain. + */ +export function GetDiditWebhookUrl( + hostname: string = typeof window !== 'undefined' + ? window.location.hostname + : '' +): string { + return ( + ZONELESS_DASHBOARD_DIDIT_WEBHOOK_URLS[hostname] ?? + `https://api.yourdomain.com${DIDIT_WEBHOOK_PATH}` + ); +} diff --git a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.html b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.html index 7116442..8732ac8 100644 --- a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.html +++ b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.html @@ -58,9 +58,13 @@ Webhook secret Optional

- Shared secret from your Didit webhook destination. Point Didit at - /v1/identity/webhooks/didit on this instance. + Shared secret from your Didit webhook destination. Point Didit at:

+
- Payout volume threshold + Default payout volume threshold Optional

Require identity verification after a connected account reaches this - lifetime paid payout volume (USD). Leave blank to disable. + lifetime paid payout volume (USD), unless a country override applies. + Leave blank to disable the default.

$ @@ -107,4 +112,61 @@
{{ payoutVolumeThresholdError() }}
}
+ +
+
+ Country thresholds Optional +
+

+ Override the default payout threshold for specific countries. +

+ + @for (row of countryThresholds(); track $index; let i = $index) { +
+ +
+ $ + +
+ +
+ } + + + + @if (showErrors && countryThresholdsError()) { +
{{ countryThresholdsError() }}
+ } +
diff --git a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.scss b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.scss index 539e2f0..5fde471 100644 --- a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.scss +++ b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.scss @@ -1,5 +1,9 @@ @use '../../../styles/base.scss' as *; @use '../../../styles/forms.scss' as *; +@use '../../../styles/buttons.scss' as *; +@use '../../../styles/account.scss' as *; +@use '../../../styles/align.scss' as *; +@use '../../../styles/spacing.scss' as *; .identity-settings-form { display: flex; @@ -30,6 +34,7 @@ color: $text-color; opacity: $dimmed; font-size: $font-size; + line-height: 1; } .field-value { @@ -37,9 +42,38 @@ } } -code { - font-size: $font-size-small; - background-color: $darker-background-color; - padding: 1px $spacing-small; - border-radius: $border-radius-small; +.country-threshold-row { + display: flex; + align-items: center; + gap: $spacing-small; + margin-bottom: $spacing-small; + + .country-threshold-select { + flex: 1; + min-width: 0; + height: 33px; + margin: 0; + } + + .country-threshold-amount { + width: 120px; + flex-shrink: 0; + height: 33px; + + .field-value { + width: 100%; + height: 33px; + box-sizing: border-box; + } + } + + .text-link { + flex-shrink: 0; + } +} + +.webhook-url-copy { + display: block; + margin-top: $spacing-small; + margin-bottom: $spacing-small; } diff --git a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.ts b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.ts index 2bc5968..1107b94 100644 --- a/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.ts +++ b/apps/web/src/app/shared/forms/identity-settings-form/identity-settings-form.component.ts @@ -13,19 +13,29 @@ import { import { FormsModule } from '@angular/forms'; import { Account } from '@zoneless/shared-types'; import { UpdateAccountInput } from '@zoneless/shared-schemas'; +import { CopyTextComponent } from '../../ui'; +import { ISO_CODES } from '../../../utils'; +import { GetDiditWebhookUrl } from './didit-webhook-url'; export interface IdentitySettingsFormData { apiKey: string; workflowId: string; webhookSecret: string; - /** Dollars string for the payout volume threshold (converted to cents on save) */ + /** Dollars string for the default payout volume threshold */ payoutVolumeThreshold: string; + countryThresholds: IdentityCountryThresholdFormRow[]; +} + +export interface IdentityCountryThresholdFormRow { + country: string; + /** Dollars string (converted to cents on save) */ + threshold: string; } @Component({ selector: 'app-identity-settings-form', standalone: true, - imports: [FormsModule], + imports: [FormsModule, CopyTextComponent], templateUrl: './identity-settings-form.component.html', styleUrls: ['./identity-settings-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -38,6 +48,12 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { @Output() formChange = new EventEmitter(); @Output() validationChange = new EventEmitter(); + readonly ISO_CODES = [...ISO_CODES].sort((a, b) => + a.country.localeCompare(b.country) + ); + + readonly diditWebhookUrl = GetDiditWebhookUrl(); + apiKey: WritableSignal = signal(''); apiKeyError: WritableSignal = signal(''); apiKeyConfigured: WritableSignal = signal(false); @@ -52,6 +68,11 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { payoutVolumeThreshold: WritableSignal = signal(''); payoutVolumeThresholdError: WritableSignal = signal(''); + countryThresholds: WritableSignal = signal( + [] + ); + countryThresholdsError: WritableSignal = signal(''); + ngOnInit(): void { this.InitializeForm(); } @@ -67,24 +88,38 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { InitializeForm(): void { const identity = this.account?.settings?.identity; - const didit = identity?.didit; + const providerSettings = identity?.didit; const rules = identity?.rules; this.apiKey.set(''); this.webhookSecret.set(''); - this.apiKeyConfigured.set(!!didit?.api_key_set); - this.webhookSecretConfigured.set(!!didit?.webhook_secret_set); - this.workflowId.set(didit?.workflow_id?.trim() || ''); + this.apiKeyConfigured.set(!!providerSettings?.api_key_set); + this.webhookSecretConfigured.set(!!providerSettings?.webhook_secret_set); + this.workflowId.set(providerSettings?.workflow_id?.trim() || ''); const cents = rules?.payout_volume_threshold_cents; this.payoutVolumeThreshold.set( cents != null && cents >= 0 ? String(cents / 100) : '' ); + // One UI row per country (API may group countries that share a threshold) + const rows: IdentityCountryThresholdFormRow[] = []; + for (const row of rules?.country_thresholds ?? []) { + const threshold = + row.payout_volume_threshold_cents != null + ? String(row.payout_volume_threshold_cents / 100) + : ''; + for (const code of row.countries ?? []) { + rows.push({ country: code, threshold }); + } + } + this.countryThresholds.set(rows); + this.apiKeyError.set(''); this.workflowIdError.set(''); this.webhookSecretError.set(''); this.payoutVolumeThresholdError.set(''); + this.countryThresholdsError.set(''); this.EmitFormChange(); } @@ -92,12 +127,14 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { OnApiKeyChange(value: string): void { this.apiKey.set(value); this.ValidateApiKey(); + this.ValidateThresholdRequiresProvider(); this.EmitFormChange(); } OnWorkflowIdChange(value: string): void { this.workflowId.set(value); this.ValidateWorkflowId(); + this.ValidateThresholdRequiresProvider(); this.EmitFormChange(); } @@ -110,14 +147,66 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { OnPayoutVolumeThresholdChange(value: string): void { this.payoutVolumeThreshold.set(value); this.ValidatePayoutVolumeThreshold(); + this.ValidateThresholdRequiresProvider(); + this.EmitFormChange(); + } + + AddCountryThresholdRow(): void { + this.countryThresholds.set([ + ...this.countryThresholds(), + { country: '', threshold: '' }, + ]); + this.ValidateCountryThresholds(); + this.ValidateThresholdRequiresProvider(); this.EmitFormChange(); } + RemoveCountryThresholdRow(index: number): void { + this.countryThresholds.set( + this.countryThresholds().filter((_, i) => i !== index) + ); + this.ValidateCountryThresholds(); + this.ValidateThresholdRequiresProvider(); + this.EmitFormChange(); + } + + OnCountryChange(index: number, code: string): void { + const rows = this.countryThresholds().map((row, i) => + i === index ? { ...row, country: code } : row + ); + this.countryThresholds.set(rows); + this.ValidateCountryThresholds(); + this.ValidateThresholdRequiresProvider(); + this.EmitFormChange(); + } + + OnCountryThresholdChange(index: number, value: string): void { + const rows = this.countryThresholds().map((row, i) => + i === index ? { ...row, threshold: value } : row + ); + this.countryThresholds.set(rows); + this.ValidateCountryThresholds(); + this.ValidateThresholdRequiresProvider(); + this.EmitFormChange(); + } + + AvailableCountriesForRow(index: number): typeof this.ISO_CODES { + const usedElsewhere = new Set( + this.countryThresholds() + .filter((_, i) => i !== index) + .map((row) => row.country) + .filter(Boolean) + ); + return this.ISO_CODES.filter((c) => !usedElsewhere.has(c.code)); + } + ValidateAll(): boolean { this.ValidateApiKey(); this.ValidateWorkflowId(); this.ValidateWebhookSecret(); this.ValidatePayoutVolumeThreshold(); + this.ValidateCountryThresholds(); + this.ValidateThresholdRequiresProvider(); const valid = this.IsValid(); this.validationChange.emit(valid); return valid; @@ -128,7 +217,8 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { !this.apiKeyError() && !this.workflowIdError() && !this.webhookSecretError() && - !this.payoutVolumeThresholdError() + !this.payoutVolumeThresholdError() && + !this.countryThresholdsError() ); } @@ -138,6 +228,7 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { workflowId: this.workflowId(), webhookSecret: this.webhookSecret(), payoutVolumeThreshold: this.payoutVolumeThreshold(), + countryThresholds: this.countryThresholds(), }; } @@ -146,7 +237,7 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { * write-only credentials are preserved. */ GetUpdateData(): UpdateAccountInput { - const didit: { + const providerCredentials: { api_key?: string; workflow_id: string | null; webhook_secret?: string; @@ -156,12 +247,12 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { const apiKey = this.apiKey().trim(); if (apiKey) { - didit.api_key = apiKey; + providerCredentials.api_key = apiKey; } const webhookSecret = this.webhookSecret().trim(); if (webhookSecret) { - didit.webhook_secret = webhookSecret; + providerCredentials.webhook_secret = webhookSecret; } const thresholdDollars = this.payoutVolumeThreshold().trim(); @@ -172,19 +263,50 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { ); } + // Group countries that share the same threshold cents + const byThreshold = new Map(); + for (const row of this.countryThresholds()) { + if (!row.country.trim() || !row.threshold.trim()) continue; + const cents = Math.round(parseFloat(row.threshold) * 100); + if (!Number.isFinite(cents)) continue; + const list = byThreshold.get(cents) ?? []; + list.push(row.country.trim().toUpperCase()); + byThreshold.set(cents, list); + } + const countryThresholds = [...byThreshold.entries()].map( + ([cents, countries]) => ({ + countries, + payout_volume_threshold_cents: cents, + }) + ); + return { settings: { identity: { provider: 'didit', - didit, + didit: providerCredentials, rules: { payout_volume_threshold_cents: payoutVolumeThresholdCents, + country_thresholds: countryThresholds, }, }, }, }; } + private HasProviderConfigured(): boolean { + const hasKey = !!this.apiKey().trim() || this.apiKeyConfigured(); + const hasWorkflow = !!this.workflowId().trim(); + return hasKey && hasWorkflow; + } + + private HasAnyThreshold(): boolean { + if (this.payoutVolumeThreshold().trim()) return true; + return this.countryThresholds().some( + (row) => row.country.trim() || row.threshold.trim() + ); + } + private ValidateApiKey(): void { const value = this.apiKey().trim(); if (!value && !this.apiKeyConfigured()) { @@ -203,7 +325,6 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { } private ValidateWebhookSecret(): void { - // Optional until webhooks are used; no hard require this.webhookSecretError.set(''); } @@ -221,6 +342,57 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { this.payoutVolumeThresholdError.set(''); } + private ValidateCountryThresholds(): void { + const rows = this.countryThresholds(); + const seen = new Set(); + + for (const row of rows) { + if (!row.country.trim() && !row.threshold.trim()) { + continue; + } + if (!row.country.trim()) { + this.countryThresholdsError.set('Select a country for each override'); + return; + } + if (!row.threshold.trim()) { + this.countryThresholdsError.set('Each override needs a threshold'); + return; + } + const value = parseFloat(row.threshold); + if (!Number.isFinite(value) || value < 0) { + this.countryThresholdsError.set( + 'Override thresholds must be $0 or more' + ); + return; + } + if (seen.has(row.country)) { + this.countryThresholdsError.set( + `Country ${row.country} appears more than once` + ); + return; + } + seen.add(row.country); + } + + this.countryThresholdsError.set(''); + } + + private ValidateThresholdRequiresProvider(): void { + if (!this.HasAnyThreshold()) return; + if (this.HasProviderConfigured()) return; + + if (!this.apiKey().trim() && !this.apiKeyConfigured()) { + this.apiKeyError.set( + 'API key is required when volume thresholds are set' + ); + } + if (!this.workflowId().trim()) { + this.workflowIdError.set( + 'Workflow ID is required when volume thresholds are set' + ); + } + } + private EmitFormChange(): void { this.formChange.emit(this.GetFormData()); this.validationChange.emit(this.IsValid()); diff --git a/libs/shared-schemas/src/lib/AccountSchema.ts b/libs/shared-schemas/src/lib/AccountSchema.ts index 9c4c186..5403173 100644 --- a/libs/shared-schemas/src/lib/AccountSchema.ts +++ b/libs/shared-schemas/src/lib/AccountSchema.ts @@ -107,11 +107,42 @@ const IdentityDiditSettingsSchema = z }) .partial(); +const IdentityCountryThresholdSchema = z.object({ + countries: z + .array( + z + .string() + .length(2, 'Country must be a 2-character ISO 3166-1 alpha-2 code') + .transform((c) => c.toUpperCase()) + ) + .min(1, 'At least one country is required'), + payout_volume_threshold_cents: z.number().int().nonnegative(), +}); + const IdentityRulesSettingsSchema = z .object({ payout_volume_threshold_cents: z.number().int().nonnegative().nullable(), + country_thresholds: z.array(IdentityCountryThresholdSchema).nullable(), }) - .partial(); + .partial() + .superRefine((rules, ctx) => { + const rows = rules.country_thresholds; + if (!rows?.length) return; + + const seen = new Set(); + for (let i = 0; i < rows.length; i++) { + for (const code of rows[i].countries) { + if (seen.has(code)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Country ${code} appears in more than one threshold override`, + path: ['country_thresholds', i, 'countries'], + }); + } + seen.add(code); + } + } + }); const IdentitySettingsSchema = z .object({ diff --git a/libs/shared-schemas/src/lib/IdentityThresholdRules.ts b/libs/shared-schemas/src/lib/IdentityThresholdRules.ts new file mode 100644 index 0000000..6e78bb2 --- /dev/null +++ b/libs/shared-schemas/src/lib/IdentityThresholdRules.ts @@ -0,0 +1,68 @@ +/** + * Resolve payout-volume IDV thresholds from platform identity rules. + * + * Resolution: first matching country_thresholds row for account.country, + * else global payout_volume_threshold_cents, else null (IDV threshold off). + */ + +import type { AccountIdentityRulesSettings } from '@zoneless/shared-types'; + +/** + * True when any payout-volume threshold rule is configured (global or country). + */ +export function HasPayoutVolumeThresholdRules( + rules: AccountIdentityRulesSettings | null | undefined +): boolean { + if (!rules) return false; + if ( + rules.payout_volume_threshold_cents != null && + rules.payout_volume_threshold_cents >= 0 + ) { + return true; + } + return (rules.country_thresholds?.length ?? 0) > 0; +} + +/** + * Resolve the lifetime paid payout volume threshold (cents) for a connected + * account country. Returns null when IDV volume gating is disabled. + */ +export function ResolvePayoutVolumeThresholdCents( + rules: AccountIdentityRulesSettings | null | undefined, + country: string | null | undefined +): number | null { + if (!rules) return null; + + const normalizedCountry = country?.trim().toUpperCase() || null; + if (normalizedCountry && rules.country_thresholds?.length) { + for (const row of rules.country_thresholds) { + const countries = (row.countries ?? []).map((c) => + c.trim().toUpperCase() + ); + if (countries.includes(normalizedCountry)) { + return row.payout_volume_threshold_cents; + } + } + } + + if ( + rules.payout_volume_threshold_cents != null && + rules.payout_volume_threshold_cents >= 0 + ) { + return rules.payout_volume_threshold_cents; + } + + return null; +} + +/** + * Format a payout volume threshold in cents as a USD display string. + */ +export function FormatPayoutVolumeThresholdCents( + thresholdCents: number | null | undefined +): string | null { + if (thresholdCents == null || thresholdCents < 0) return null; + return `$${(thresholdCents / 100).toLocaleString('en-US', { + maximumFractionDigits: 2, + })}`; +} diff --git a/libs/shared-schemas/src/lib/index.ts b/libs/shared-schemas/src/lib/index.ts index d4de5ee..e587f83 100644 --- a/libs/shared-schemas/src/lib/index.ts +++ b/libs/shared-schemas/src/lib/index.ts @@ -9,6 +9,7 @@ export * from './EventSchema'; export * from './ExpandableSchema'; export * from './ExternalWalletSchema'; export * from './IdentityValidators'; +export * from './IdentityThresholdRules'; export * from './IdentityVerificationSessionSchema'; export * from './InvoiceItemSchema'; export * from './InvoiceSchema'; diff --git a/libs/shared-types/src/lib/Account.ts b/libs/shared-types/src/lib/Account.ts index b7771ff..59003e2 100644 --- a/libs/shared-types/src/lib/Account.ts +++ b/libs/shared-types/src/lib/Account.ts @@ -461,11 +461,25 @@ export interface AccountIdentityDiditSettings { export interface AccountIdentityRulesSettings { /** - * Lifetime paid payout volume (cents) that promotes - * `individual.verification.document` to currently_due. - * null / omitted = threshold disabled. + * Default lifetime paid payout volume (cents) that promotes + * `individual.verification.document` to currently_due when no country + * override matches. null / omitted = no default threshold. */ payout_volume_threshold_cents?: number | null; + + /** + * Per-country payout volume thresholds. First matching row for + * `account.country` wins over the default. + */ + country_thresholds?: AccountIdentityCountryThreshold[] | null; +} + +export interface AccountIdentityCountryThreshold { + /** ISO 3166-1 alpha-2 country codes this override applies to */ + countries: string[]; + + /** Lifetime paid payout volume (cents) that promotes document IDV */ + payout_volume_threshold_cents: number; } export interface AccountBrandingSettings {