diff --git a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts index 487aa18..44003f4 100644 --- a/apps/api/src/__tests__/IdentityVerificationSession.spec.ts +++ b/apps/api/src/__tests__/IdentityVerificationSession.spec.ts @@ -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, @@ -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); }); }); @@ -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 () => { @@ -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 }, @@ -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'); diff --git a/apps/api/src/modules/IdentityVerificationSession.ts b/apps/api/src/modules/IdentityVerificationSession.ts index 1d0bfc9..148fbb9 100644 --- a/apps/api/src/modules/IdentityVerificationSession.ts +++ b/apps/api/src/modules/IdentityVerificationSession.ts @@ -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'; @@ -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, } ); diff --git a/apps/api/src/modules/identity/DiditProvider.ts b/apps/api/src/modules/identity/DiditProvider.ts index aeace2e..1f05798 100644 --- a/apps/api/src/modules/identity/DiditProvider.ts +++ b/apps/api/src/modules/identity/DiditProvider.ts @@ -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: { @@ -274,4 +279,16 @@ function ShortenFloats(obj: unknown): unknown { return obj; } +function CompactExpectedDetails( + details: ProviderCreateSessionInput['expectedDetails'] +): Record | null { + if (!details) return null; + const compacted: Record = {}; + 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(); diff --git a/apps/api/src/modules/identity/IdentityVerificationProvider.ts b/apps/api/src/modules/identity/IdentityVerificationProvider.ts index 68a4ecf..bb13c5d 100644 --- a/apps/api/src/modules/identity/IdentityVerificationProvider.ts +++ b/apps/api/src/modules/identity/IdentityVerificationProvider.ts @@ -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; @@ -23,6 +28,8 @@ export interface ProviderCreateSessionInput { email?: string | null; phone?: string | null; metadata?: Record; + /** Prefill for KYB company registry search */ + expectedDetails?: ProviderExpectedDetails; } export interface ProviderSession { diff --git a/apps/api/src/modules/identity/ResolveIdentityProvider.ts b/apps/api/src/modules/identity/ResolveIdentityProvider.ts index 04a6809..00e2d43 100644 --- a/apps/api/src/modules/identity/ResolveIdentityProvider.ts +++ b/apps/api/src/modules/identity/ResolveIdentityProvider.ts @@ -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'; @@ -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). @@ -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) { @@ -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. */ diff --git a/apps/web/src/app/data/services/account.service.ts b/apps/web/src/app/data/services/account.service.ts index 894bad7..c7bc4d8 100644 --- a/apps/web/src/app/data/services/account.service.ts +++ b/apps/web/src/app/data/services/account.service.ts @@ -7,6 +7,7 @@ import { UpdateAccountInput, } from '@zoneless/shared-schemas'; import { SettingsCardRow } from '../../shared'; +import { FormatBusinessType, IsBusinessAccount } from '../../utils'; @Injectable({ providedIn: 'root', @@ -222,6 +223,42 @@ export class AccountService { return email || account.id; } + /** + * Display title for the connected-account type card. + */ + GetAccountTypeTitle(account: Account | null): string { + if (!account) return 'Account type'; + if (IsBusinessAccount(account)) { + return ( + account.business_profile?.name?.trim() || + FormatBusinessType(account.business_type) + ); + } + return 'Individual'; + } + + GetAccountTypeCardRows(account: Account | null): SettingsCardRow[] { + if (!account) return []; + + const rows: SettingsCardRow[] = [ + { + label: 'Type', + value: FormatBusinessType(account.business_type), + type: 'text', + }, + ]; + + if (IsBusinessAccount(account)) { + rows.push({ + label: 'Legal business name', + value: account.business_profile?.name?.trim() || '—', + type: 'text', + }); + } + + return rows; + } + /** * Display title for the Business details settings card. */ @@ -284,10 +321,15 @@ export class AccountService { type: 'text', }, { - label: 'Workflow ID', + label: 'KYC workflow ID', value: providerSettings?.workflow_id?.trim() || '—', type: 'text', }, + { + label: 'KYB workflow ID', + value: providerSettings?.kyb_workflow_id?.trim() || '—', + type: 'text', + }, { label: 'Webhook secret', value: providerSettings?.webhook_secret_set ? 'Configured' : 'Not set', diff --git a/apps/web/src/app/features/account/components/connected-account-detail/connected-account-detail.component.ts b/apps/web/src/app/features/account/components/connected-account-detail/connected-account-detail.component.ts index 7c28c52..1ed8741 100644 --- a/apps/web/src/app/features/account/components/connected-account-detail/connected-account-detail.component.ts +++ b/apps/web/src/app/features/account/components/connected-account-detail/connected-account-detail.component.ts @@ -12,7 +12,7 @@ import { Account, Person } from '@zoneless/shared-types'; import { StatusChipComponent } from '../../../../shared'; import { AccountService } from '../../../../data/services/account.service'; import { PersonService } from '../../../../data/services/person.service'; -import { GetCountryName } from '../../../../utils'; +import { FormatBusinessType, GetCountryName } from '../../../../utils'; import { GetAccountStatus } from '../../connected-accounts/util/connected-account-display'; @Component({ @@ -80,10 +80,7 @@ export class ConnectedAccountDetailComponent { GetBusinessType(): string | null { if (!this.account.business_type) return null; - return this.account.business_type - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + return FormatBusinessType(this.account.business_type); } GetChargesEnabled(): boolean { diff --git a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts index 149afe1..8192040 100644 --- a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts +++ b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts @@ -28,7 +28,7 @@ import { TransferService, } from '../../../../data'; import { SolanaWalletService } from '../../../../core'; -import { GetCountryName } from '../../../../utils'; +import { FormatBusinessType, GetCountryName } from '../../../../utils'; export type CreateConnectedAccountStep = 'summary' | 'edit-details' | 'success'; @@ -288,16 +288,7 @@ export class ConnectedAccountActionsService { } GetBusinessTypeLabel(type: BusinessType = this.businessType()): string { - switch (type) { - case 'individual': - return 'Individual'; - case 'company': - return 'Company'; - case 'non_profit': - return 'Non-profit'; - case 'government_entity': - return 'Government entity'; - } + return FormatBusinessType(type); } GetCapabilitiesLabel(): string { diff --git a/apps/web/src/app/features/account/settings/settings.component.html b/apps/web/src/app/features/account/settings/settings.component.html index 109aa4a..6c0da8d 100644 --- a/apps/web/src/app/features/account/settings/settings.component.html +++ b/apps/web/src/app/features/account/settings/settings.component.html @@ -120,8 +120,18 @@

Settings

} - + @if (!authService.isPlatform()) { +
+
Account type
+ +
+
Personal details
Settings } - + @if (!authService.isPlatform()) { + + + + = signal(false); editIdentityShowErrors: WritableSignal = signal(false); + // Edit account type panel (connected accounts) + editAccountTypePanelOpen: WritableSignal = signal(false); + editAccountTypeLoading: WritableSignal = signal(false); + editAccountTypeShowErrors: WritableSignal = signal(false); + telemetrySaving: WritableSignal = signal(false); identityTaskDetailOpen: WritableSignal = signal(false); @@ -307,6 +316,44 @@ export class SettingsComponent implements OnInit { } } + OnEditAccountTypeClick(): void { + this.editAccountTypeShowErrors.set(false); + this.editAccountTypePanelOpen.set(true); + } + + OnEditAccountTypePanelClosed(): void { + this.editAccountTypePanelOpen.set(false); + this.editAccountTypeShowErrors.set(false); + } + + async OnEditAccountTypeSubmit(): Promise { + if (!this.editAccountTypeForm) return; + + this.editAccountTypeShowErrors.set(true); + + if (!this.editAccountTypeForm.ValidateAll()) { + return; + } + + const account = this.GetAccount(); + if (!account) return; + + this.editAccountTypeLoading.set(true); + + try { + await this.accountService.UpdateAccount( + account.id, + this.editAccountTypeForm.GetUpdateData() + ); + this.editAccountTypePanelOpen.set(false); + this.editAccountTypeShowErrors.set(false); + } catch (error) { + console.error('Failed to update account type:', error); + } finally { + this.editAccountTypeLoading.set(false); + } + } + // Edit Person Panel OnEditPersonClick(): void { this.editPersonShowErrors.set(false); diff --git a/apps/web/src/app/features/onboard/onboard.component.html b/apps/web/src/app/features/onboard/onboard.component.html index 5c9129c..8604154 100644 --- a/apps/web/src/app/features/onboard/onboard.component.html +++ b/apps/web/src/app/features/onboard/onboard.component.html @@ -85,7 +85,15 @@

Something went wrong

[showErrors]="showPersonErrors()" (formChange)="OnPersonFormChange()" (validationChange)="OnPersonValidationChange($event)" - > + > + +
@if(apiError()){ @@ -144,9 +152,32 @@

Review and submit

Take a moment to review your information.

+ +
+
Account type
+ +
+
-
Personal details
+
+ {{ + ShowBusinessDetails() + ? 'Account representative' + : 'Personal details' + }} +
+ + + = signal(false); personFormValid: WritableSignal = signal(false); + accountTypeFormValid: WritableSignal = signal(true); walletFormValid: WritableSignal = signal(false); // Edit panel state @@ -91,6 +99,10 @@ export class OnboardComponent implements OnInit { editWalletLoading: WritableSignal = signal(false); editWalletShowErrors: WritableSignal = signal(false); + editAccountTypePanelOpen: WritableSignal = signal(false); + editAccountTypeLoading: WritableSignal = signal(false); + editAccountTypeShowErrors: WritableSignal = signal(false); + private tokenExchanged = false; private initRetries = 0; private readonly MAX_INIT_RETRIES = 3; @@ -188,6 +200,14 @@ export class OnboardComponent implements OnInit { this.personFormValid.set(isValid); } + OnAccountTypeValidationChange(isValid: boolean): void { + this.accountTypeFormValid.set(isValid); + } + + ShowBusinessDetails(): boolean { + return IsBusinessAccount(this.accountService.account()); + } + OnWalletValidationChange(isValid: boolean): void { this.walletFormValid.set(isValid); } @@ -216,7 +236,7 @@ export class OnboardComponent implements OnInit { IsStepValid(step: number): boolean { switch (step) { case OnboardStep.PERSON: - return this.personFormValid(); + return this.personFormValid() && this.accountTypeFormValid(); case OnboardStep.WALLET: return this.walletFormValid(); case OnboardStep.FINISH: @@ -264,10 +284,20 @@ export class OnboardComponent implements OnInit { const account = this.accountService.account(); const person = this.personService.person(); - if (!account || !person || !this.personForm) { + if (!account || !person || !this.personForm || !this.accountTypeForm) { throw new Error('Account or person not found'); } + if (!this.accountTypeForm.ValidateAll()) { + this.showPersonErrors.set(true); + throw new Error('Please complete your account type details'); + } + + await this.accountService.UpdateAccount( + account.id, + this.accountTypeForm.GetUpdateData() + ); + const updateData = this.personForm.GetUpdateData(); const updated = await this.personService.UpdatePerson( account.id, @@ -394,6 +424,44 @@ export class OnboardComponent implements OnInit { } } + OnEditAccountTypeClick(): void { + this.editAccountTypeShowErrors.set(false); + this.editAccountTypePanelOpen.set(true); + } + + OnEditAccountTypePanelClosed(): void { + this.editAccountTypePanelOpen.set(false); + this.editAccountTypeShowErrors.set(false); + } + + async OnEditAccountTypeSubmit(): Promise { + if (!this.editAccountTypeForm) return; + + this.editAccountTypeShowErrors.set(true); + + if (!this.editAccountTypeForm.ValidateAll()) { + return; + } + + const account = this.accountService.account(); + if (!account) return; + + this.editAccountTypeLoading.set(true); + + try { + await this.accountService.UpdateAccount( + account.id, + this.editAccountTypeForm.GetUpdateData() + ); + this.editAccountTypePanelOpen.set(false); + this.editAccountTypeShowErrors.set(false); + } catch (error) { + console.error('Failed to update account type:', error); + } finally { + this.editAccountTypeLoading.set(false); + } + } + // Edit Wallet Panel OnEditWalletClick(): void { this.editWalletShowErrors.set(false); diff --git a/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.html b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.html new file mode 100644 index 0000000..aff79f3 --- /dev/null +++ b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.html @@ -0,0 +1,44 @@ + diff --git a/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.scss b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.scss new file mode 100644 index 0000000..afa2231 --- /dev/null +++ b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.scss @@ -0,0 +1,17 @@ +@use '../../../styles/base.scss' as *; +@use '../../../styles/forms.scss' as *; + +.account-type-form { + display: flex; + flex-direction: column; + gap: $spacing; + margin-bottom: $spacing; +} + +.field-group { + margin-bottom: $spacing-small; +} + +select { + width: 100%; +} diff --git a/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.ts b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.ts new file mode 100644 index 0000000..2161c76 --- /dev/null +++ b/apps/web/src/app/shared/forms/account-type-form/account-type-form.component.ts @@ -0,0 +1,153 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnChanges, + SimpleChanges, + signal, + WritableSignal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Account, AccountBusinessType } from '@zoneless/shared-types'; +import { UpdateAccountInput } from '@zoneless/shared-schemas'; +import { + IsBusinessAccount, + NAME_MAX_LENGTH, + NAME_MIN_LENGTH, +} from '../../../utils'; + +export type AccountTypeFormMode = 'onboard' | 'edit'; + +export interface AccountTypeFormData { + businessType: AccountBusinessType; + businessName: string; +} + +type AccountTypeChoice = 'individual' | 'company'; + +@Component({ + selector: 'app-account-type-form', + standalone: true, + imports: [FormsModule], + templateUrl: './account-type-form.component.html', + styleUrls: ['./account-type-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountTypeFormComponent implements OnInit, OnChanges { + readonly NAME_MAX_LENGTH = NAME_MAX_LENGTH; + + @Input() mode: AccountTypeFormMode = 'onboard'; + @Input() account: Account | null = null; + @Input() showErrors = false; + @Input() isOpen = false; + + @Output() formChange = new EventEmitter(); + @Output() validationChange = new EventEmitter(); + + accountType: WritableSignal = signal('individual'); + businessName: WritableSignal = signal(''); + businessNameError: WritableSignal = signal(''); + + ngOnInit(): void { + this.InitializeForm(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['isOpen'] && this.isOpen) { + this.InitializeForm(); + } + if (changes['account'] && !this.isOpen) { + this.InitializeForm(); + } + } + + InitializeForm(): void { + this.accountType.set( + IsBusinessAccount(this.account) ? 'company' : 'individual' + ); + this.businessName.set(this.account?.business_profile?.name?.trim() || ''); + this.businessNameError.set(''); + this.EmitFormChange(); + } + + OnAccountTypeChange(value: string): void { + this.accountType.set(value === 'company' ? 'company' : 'individual'); + this.ValidateBusinessName(); + this.EmitFormChange(); + } + + OnBusinessNameChange(value: string): void { + this.businessName.set(value); + this.ValidateBusinessName(); + this.EmitFormChange(); + } + + IsBusiness(): boolean { + return this.accountType() === 'company'; + } + + ValidateAll(): boolean { + this.ValidateBusinessName(); + const valid = this.IsValid(); + this.validationChange.emit(valid); + return valid; + } + + IsValid(): boolean { + if (!this.IsBusiness()) return true; + return !!this.businessName().trim() && !this.businessNameError(); + } + + GetFormData(): AccountTypeFormData { + return { + businessType: this.ResolveBusinessType(), + businessName: this.businessName().trim(), + }; + } + + GetUpdateData(): UpdateAccountInput { + const isBusiness = this.IsBusiness(); + return { + business_type: this.ResolveBusinessType(), + business_profile: { + name: isBusiness ? this.businessName().trim() : null, + }, + }; + } + + private ResolveBusinessType(): AccountBusinessType { + if (!this.IsBusiness()) return 'individual'; + const existing = this.account?.business_type; + if (existing === 'non_profit' || existing === 'government_entity') { + return existing; + } + return 'company'; + } + + private ValidateBusinessName(): void { + if (!this.IsBusiness()) { + this.businessNameError.set(''); + return; + } + const name = this.businessName().trim(); + if (!name) { + this.businessNameError.set('Legal business name is required'); + return; + } + if (name.length < NAME_MIN_LENGTH) { + this.businessNameError.set( + `Business name must be at least ${NAME_MIN_LENGTH} characters` + ); + return; + } + this.businessNameError.set(''); + } + + private EmitFormChange(): void { + this.formChange.emit(this.GetFormData()); + this.validationChange.emit(this.IsValid()); + } +} 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 8732ac8..ca18519 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 @@ -34,9 +34,10 @@
-
Workflow ID
+
KYC workflow ID

- The published Didit workflow used for KYC verification sessions. + The published Didit workflow used for individual KYC verification + sessions.

+
+
+ KYB workflow ID Optional +
+

+ The published Didit workflow used for company verification sessions. When + unset, business accounts use the KYC workflow. +

+ +
+
Webhook secret Optional 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 1107b94..cff2f1a 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 @@ -20,6 +20,7 @@ import { GetDiditWebhookUrl } from './didit-webhook-url'; export interface IdentitySettingsFormData { apiKey: string; workflowId: string; + kybWorkflowId: string; webhookSecret: string; /** Dollars string for the default payout volume threshold */ payoutVolumeThreshold: string; @@ -61,6 +62,8 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { workflowId: WritableSignal = signal(''); workflowIdError: WritableSignal = signal(''); + kybWorkflowId: WritableSignal = signal(''); + webhookSecret: WritableSignal = signal(''); webhookSecretError: WritableSignal = signal(''); webhookSecretConfigured: WritableSignal = signal(false); @@ -96,6 +99,7 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { this.apiKeyConfigured.set(!!providerSettings?.api_key_set); this.webhookSecretConfigured.set(!!providerSettings?.webhook_secret_set); this.workflowId.set(providerSettings?.workflow_id?.trim() || ''); + this.kybWorkflowId.set(providerSettings?.kyb_workflow_id?.trim() || ''); const cents = rules?.payout_volume_threshold_cents; this.payoutVolumeThreshold.set( @@ -138,6 +142,11 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { this.EmitFormChange(); } + OnKybWorkflowIdChange(value: string): void { + this.kybWorkflowId.set(value); + this.EmitFormChange(); + } + OnWebhookSecretChange(value: string): void { this.webhookSecret.set(value); this.ValidateWebhookSecret(); @@ -226,6 +235,7 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { return { apiKey: this.apiKey(), workflowId: this.workflowId(), + kybWorkflowId: this.kybWorkflowId(), webhookSecret: this.webhookSecret(), payoutVolumeThreshold: this.payoutVolumeThreshold(), countryThresholds: this.countryThresholds(), @@ -240,9 +250,11 @@ export class IdentitySettingsFormComponent implements OnInit, OnChanges { const providerCredentials: { api_key?: string; workflow_id: string | null; + kyb_workflow_id: string | null; webhook_secret?: string; } = { workflow_id: this.workflowId().trim() || null, + kyb_workflow_id: this.kybWorkflowId().trim() || null, }; const apiKey = this.apiKey().trim(); diff --git a/apps/web/src/app/shared/forms/index.ts b/apps/web/src/app/shared/forms/index.ts index f7ca094..917ad6d 100644 --- a/apps/web/src/app/shared/forms/index.ts +++ b/apps/web/src/app/shared/forms/index.ts @@ -1,3 +1,4 @@ +export * from './account-type-form/account-type-form.component'; export * from './person-form/person-form.component'; export * from './external-wallet-form/external-wallet-form.component'; export * from './business-profile-form/business-profile-form.component'; diff --git a/apps/web/src/app/shared/forms/person-form/person-form.component.html b/apps/web/src/app/shared/forms/person-form/person-form.component.html index 723503e..88b1471 100644 --- a/apps/web/src/app/shared/forms/person-form/person-form.component.html +++ b/apps/web/src/app/shared/forms/person-form/person-form.component.html @@ -21,6 +21,9 @@

Verify your personal details

} + + +
Your legal name
diff --git a/apps/web/src/app/styles/forms.scss b/apps/web/src/app/styles/forms.scss index b7dcf2d..a741b16 100644 --- a/apps/web/src/app/styles/forms.scss +++ b/apps/web/src/app/styles/forms.scss @@ -142,7 +142,10 @@ select { cursor: pointer; border-radius: $border-radius-small; font-family: $text-font; - height: 33px; /*Forces to same height as an input*/ + box-sizing: border-box; + min-height: 36px; + height: auto; + line-height: 1.3; } option { diff --git a/apps/web/src/app/utils/account-type.ts b/apps/web/src/app/utils/account-type.ts new file mode 100644 index 0000000..52f3597 --- /dev/null +++ b/apps/web/src/app/utils/account-type.ts @@ -0,0 +1,34 @@ +import { Account, AccountBusinessType } from '@zoneless/shared-types'; + +/** + * True when the account represents a legal entity rather than a person. + */ +export function IsBusinessAccount( + account: + | Account + | { business_type?: AccountBusinessType | null } + | null + | undefined +): boolean { + const type = account?.business_type; + return ( + type === 'company' || type === 'non_profit' || type === 'government_entity' + ); +} + +const BUSINESS_TYPE_LABELS: Record = { + individual: 'Individual', + company: 'Company', + non_profit: 'Non-profit', + government_entity: 'Government entity', +}; + +/** + * Human-readable label for Account.business_type. + */ +export function FormatBusinessType( + businessType: AccountBusinessType | null | undefined +): string { + if (!businessType) return BUSINESS_TYPE_LABELS.individual; + return BUSINESS_TYPE_LABELS[businessType]; +} diff --git a/apps/web/src/app/utils/index.ts b/apps/web/src/app/utils/index.ts index 6df112c..bdff09f 100644 --- a/apps/web/src/app/utils/index.ts +++ b/apps/web/src/app/utils/index.ts @@ -1,2 +1,3 @@ +export * from './account-type'; export * from './constants'; export * from './validation'; diff --git a/libs/shared-schemas/src/lib/AccountSchema.ts b/libs/shared-schemas/src/lib/AccountSchema.ts index 5403173..39afaf9 100644 --- a/libs/shared-schemas/src/lib/AccountSchema.ts +++ b/libs/shared-schemas/src/lib/AccountSchema.ts @@ -103,6 +103,7 @@ const IdentityDiditSettingsSchema = z .object({ api_key: z.string().min(1).max(512).nullable(), workflow_id: z.string().min(1).max(255).nullable(), + kyb_workflow_id: z.string().min(1).max(255).nullable(), webhook_secret: z.string().min(1).max(512).nullable(), }) .partial(); diff --git a/libs/shared-types/src/lib/Account.ts b/libs/shared-types/src/lib/Account.ts index 59003e2..f8fcf59 100644 --- a/libs/shared-types/src/lib/Account.ts +++ b/libs/shared-types/src/lib/Account.ts @@ -437,9 +437,15 @@ export interface AccountIdentityDiditSettings { */ api_key?: string | null; - /** Didit workflow ID used when creating verification sessions */ + /** Didit KYC workflow ID used for individual verification sessions */ workflow_id?: string | null; + /** + * Didit KYB workflow ID used for company / non-profit / government accounts. + * When unset, business accounts fall back to workflow_id. + */ + kyb_workflow_id?: string | null; + /** * Didit webhook destination shared secret. * Write-only: encrypted at rest; redacted (null) on retrieve.