Direct ZFA client channel — design doc
Context
Today the platform models one business flow:
CLIENT ⇄ AGENT ⇄ ZFA (InsureLink) ⇄ INSURER
The Business flow doc walks that pipeline end-to-end.
ZFA also serves clients who contact them directly — walk-ins, direct web form submissions, referrals from partner organisations, or clients who never engage a licensed agent. That's a second flow the platform must model without duplicating the entire referral / policy / commission stack:
CLIENT ⇄ ZFA (InsureLink) ⇄ INSURER
The rest of this doc walks through what changes.
Guiding principles
- One referral pipeline. The 9-state referral workflow, POP flow, quotations, policies, claims, commissions, and audit chain do not fork. The new channel plugs into the existing pipeline at the front — everything downstream is unchanged.
- Nullable
agentId. The referral schema flipsagentIdfrom required to optional. Existing agent-mediated referrals stay the default; the direct channel simply omits it. - Channel is a first-class dimension. Every referral records
channelSource ∈ { agent_referral, zfa_direct, client_self_service }so reports, compliance filters, and commission logic can branch without inference. - Commissions accrue to a house wallet. The insurer still pays ZFA the full commission. With no agent to remit to, the credit lands in a dedicated ZFA house wallet — auditable, exportable, and never entangled with any agent's balance.
- The client is a first-class entity. Today "client" is a bag of
fields hanging off each referral. Direct-channel clients need
accounts, notification preferences, DSAR rights, and a portal login.
This becomes a proper
Clientmodel with its own lifecycle. - Compliance stays honest. IRA / URA reporting distinguishes agent-mediated business (which counts toward the agent's productivity) from direct-channel business (which reports under ZFA's institutional book). Regulator exports honour the split.
Actor model
Three sub-flows within the direct channel
| Sub-flow | Entry point | Client identity | Owner |
|---|---|---|---|
| Public self-service | Anonymous form on the marketing site → POST /public/client-inquiries | Created lazily on first response, magic-link login sent by email | ZFA operations queue |
| Portal-driven | Client already has a portal session (previous policyholder, renewed via OTP) → POST /client-portal/inquiries | Existing ClientPortalSession | Same client, own record |
| ZFA-mediated | Walk-in / phone call — ZFA staff creates on behalf → POST /admin/zfa-direct/inquiries | ZFA staff selects/creates client | ZFA staff member as zfaDirectHandlerId |
All three feed the same Referral table with channelSource='zfa_direct'
and agentId=NULL.
Data model changes
1. New enum + column on Referral
enum ReferralChannelSource {
agent_referral // existing default; requires agentId
zfa_direct // client contacted ZFA directly
client_self_service // client entered via public form or portal
}
model Referral {
// Existing fields (agentId, insurerId, insurerProductId, client fields…)
agentId String? @db.Uuid // was required
channelSource ReferralChannelSource @default(agent_referral)
// Who at ZFA is handling this direct-channel referral (nullable for
// agent-mediated referrals).
zfaDirectHandlerId String? @db.Uuid
// Optional link to the first-class Client entity when we've created
// one — filled in on first portal login.
clientId String? @db.Uuid
agent Agent? @relation(fields: [agentId], references: [id])
zfaDirectHandler User? @relation("ZfaDirectHandler", fields: [zfaDirectHandlerId], references: [id])
client Client? @relation(fields: [clientId], references: [id])
}
2. First-class Client entity
model Client {
id String @id @default(uuid()) @db.Uuid
code String @unique // CLI-26-XXXXXXX
firstName String
lastName String
email String? // PII, cipher-hashed for search
emailHash String? @unique
phone String?
phoneHash String? @unique
nationalId String?
nationalIdHash String? @unique
dob DateTime?
address String?
channelSource ReferralChannelSource @default(zfa_direct)
// First and last time this client transacted (any referral, any policy,
// any claim). Drives dormancy sweeps.
firstSeenAt DateTime @default(now())
lastActiveAt DateTime @default(now())
// Portal login is via OTP; no password stored on the Client.
portalEnabledAt DateTime?
// Compliance
consentAt DateTime?
consentEvidenceId String? @db.Uuid
dsarErasedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
referrals Referral[]
policies Policy[]
claims Claim[]
portalSessions ClientPortalSession[]
@@index([channelSource])
@@index([emailHash])
@@index([phoneHash])
@@map("clients")
}
Notes:
- PII encryption is identical to the existing referral PII fields —
PiiCipherfor encryption,PiiCipherHmacfor the search hash. - The
codefollows the same generator pattern as agent codes (CLI-<year>-<random>). - Backfill for existing agent-mediated referrals: no
Clientrow is created retroactively. Only referrals with aclientIdFK are linked to a client; the rest keep their inline PII on the referral itself. This keeps the migration surgical and idempotent.
3. ZFA house wallet
Reuse the existing Wallet model with a new owner-type flag:
enum WalletOwnerType {
agent // existing
zfa_house // new — house account for direct-channel commissions
}
model Wallet {
id String @id @default(uuid()) @db.Uuid
ownerType WalletOwnerType @default(agent)
agentId String? @db.Uuid // was required — now nullable
// No new fields for zfa_house — the wallet id itself uniquely identifies it.
}
Bootstrap: seed one row { ownerType: 'zfa_house', agentId: null }.
Direct-channel commissions credit to that wallet id at
commission.approve time.
4. New permissions
Add to the SystemPermissions catalogue:
zfa_direct:inquiry:create
zfa_direct:inquiry:read
zfa_direct:inquiry:assign
zfa_direct:inquiry:manage
zfa_direct:client:read
zfa_direct:client:write
zfa_house:wallet:read
zfa_house:wallet:withdraw
zfa_house:wallet:reconcile
Roles:
- ZFA operations staff — the queue handlers — get
zfa_direct:inquiry:*+client:read+client:write. - Finance — get
zfa_house:wallet:*. - Client (portal role) — gets
client:read:ownandzfa_direct:inquiry:create(with object-scope so they can only see their own).
Endpoints
Every route the direct channel adds. All slot into the module-grouped API reference so the sidebar picks them up automatically.
Public entry (magic-link + OTP)
| Method | Path | Purpose |
|---|---|---|
POST | /public/client-inquiries | Anonymous inquiry submission. Rate-limited 5/min/IP. Triggers a magic-link email to the supplied address. |
POST | /public/client-inquiries/:id/claim | Client clicks the magic-link → verifies email → gets an OTP → verifies OTP → issued a ClientPortalSession linked to a lazily-created Client. |
Client portal (session-guarded)
| Method | Path | Purpose |
|---|---|---|
GET | /client-portal/me/inquiries | List of the client's own inquiries. |
POST | /client-portal/inquiries | Create a new inquiry as an authenticated portal client (returning customer). |
GET | /client-portal/me/referrals | Referrals derived from the client's inquiries. |
POST | /client-portal/referrals/:id/pop | Upload proof-of-payment directly (bypasses agent). |
Admin / ZFA-direct desk
| Method | Path | Purpose |
|---|---|---|
POST | /admin/zfa-direct/inquiries | ZFA staff creates on behalf of a walk-in client. |
POST | /admin/zfa-direct/inquiries/:id/assign | Assign to a specific ZFA handler. |
POST | /admin/zfa-direct/inquiries/:id/convert-to-referral | Convert the inquiry into a full Referral (channelSource=zfa_direct, agentId=NULL, zfaDirectHandlerId=<current user>). From here the existing referral pipeline takes over. |
GET | /admin/zfa-house/wallet | Read the house wallet ledger. |
POST | /admin/zfa-house/wallet/withdraw | Move funds out of the house wallet (internal reconciliation with ZFA's operating account). |
GET | /admin/clients | List / search the new first-class Client entities. |
GET | /admin/clients/:id | Client profile — inquiries, referrals, policies, claims, DSAR export. |
Referral state machine — reused
No changes to the existing state machine. Direct-channel referrals traverse the same states; the differences are:
channelSource='zfa_direct'on the referral row.agentId=NULL— no agent to attribute to.zfaDirectHandlerId=<staff-uuid>— the ZFA person who owns the referral.- Object-authorization service treats
zfaDirectHandlerIdthe same way it treatsagentIdfor scoping: staff see their own direct-channel referrals viazfa_direct:inquiry:read:own.
The POP flow, quotation flow, and policy issuance are unchanged.
Commission flow — direct channel
Deductions engine changes:
- ZFA fee: not applied (
skipIfBeneficiaryIsHouse: true) — you can't charge yourself. - URA withholding: still applied — WHT is on the gross regardless of who the payee is.
- IIU membership levy: not applied (levy is per-agent, not institutional).
- The remainder settles to the house wallet.
The deductions rule engine is already versioned + configurable, so
adding a beneficiaryType targeting field is a schema-safe extension:
model DeductionType {
// Existing fields...
beneficiaryTypes String[] @default(["agent"]) // {"agent","zfa_house","both"}
}
Default ["agent"] → existing rules apply only to agent wallets, exactly
matching current behaviour.
Object-authorization changes
ObjectAuthorizationService.assertReferralAccess() currently checks
referral.agentId === user.agentId for agent scoping. Extend it:
assertReferralAccess(user: AuthenticatedUser, referral: ReferralOwnershipShape): void {
if (this.isSuperOrOps(user) || this.isFinance(user) || this.isCompliance(user)) return;
// Agent-mediated: existing rule.
if (this.isAgent(user) && user.agentId && referral.agentId === user.agentId) return;
// Sponsor scoping unchanged.
if (this.isAgent(user) && user.agentId && referral.agent?.sponsorAgentId === user.agentId) return;
// NEW: ZFA-direct desk handler scoping.
if (
this.isZfaDirectDeskStaff(user) &&
referral.channelSource === 'zfa_direct' &&
referral.zfaDirectHandlerId === user.id
) return;
// NEW: portal client scoping — the client sees their own referrals.
if (this.isPortalClient(user) && referral.clientId === user.clientId) return;
if (this.isInsurerUser(user) && user.insurerId && referral.insurerId === user.insurerId) return;
throw ApiError.forbidden('You cannot access this referral.');
}
Reporting
Add a channelSource group-by to every existing report:
- Agent report — unchanged; agent-mediated only (already implicit).
- Referral pipeline report — new column
channelSource. - Insurer performance report — split rows by
channelSource. - Commission report — split rows by
beneficiaryType(agent vs house). - Regulator aggregates — mandatory split so IRA/URA see both books.
New report:
GET /reports/zfa-direct— direct-channel-specific view: inquiries by handler, conversion rate to referral, average time-to-issue, average premium size, house-wallet accumulation.
Compliance & audit
Nothing here is new architecture — every state change goes through the
existing audit.record() and gets both the DB hash-chain row and the
Track-3 structured log line. What's new is the vocabulary:
inquiry.create.public
inquiry.create.portal
inquiry.create.admin
inquiry.assign
inquiry.convert
client.create
client.update
client.dsar.export
client.dsar.erase
zfa_house.wallet.credit
zfa_house.wallet.debit
zfa_house.wallet.reconcile
Every event carries channelSource on its after payload so SIEM
queries can filter on channel without joining tables.
DSAR: portal clients can request export/erase of their own data via the
existing DSAR endpoints. Client records get standard erasure semantics
(tombstone row, PII zeroed, dsarErasedAt stamped).
Migration strategy
Phase 1 — Schema + shims (1 week)
- Prisma migration:
ReferralChannelSourceenum,Referral.channelSourcecolumn defaulting toagent_referral(backfill all existing rows).Referral.agentId→ nullable.Referral.zfaDirectHandlerId,Referral.clientId— nullable FKs.Clienttable +WalletOwnerTypeenum +Wallet.ownerTypecolumn defaulting toagent.DeductionType.beneficiaryTypescolumn defaulting to["agent"].
- Seed: one
zfa_housewallet row. - New permissions in the seed permissions catalogue.
- ObjectAuthorizationService extension.
- Existing endpoints keep working because every new column has a safe default.
Phase 2 — Public + admin entry (1 week)
POST /public/client-inquiries+ magic-link + OTP claim flow.POST /admin/zfa-direct/inquiries+ assignment + convert-to-referral.ClientCRUD (list, get, create, update).- Basic
/reports/zfa-direct.
Phase 3 — Portal (1 week)
- Extend the existing client portal:
GET /client-portal/me/inquiriesPOST /client-portal/inquiriesGET /client-portal/me/referralsPOST /client-portal/referrals/:id/pop
- Portal UI (out of scope for this doc — separate frontend PR).
Phase 4 — Commission handling + house wallet (1 week)
- Deductions engine
beneficiaryTypesgate. - Commission approval routes
channelSource='zfa_direct'credits to the house wallet. GET /admin/zfa-house/wallet+ ledger view.POST /admin/zfa-house/wallet/withdraw(finance-only).
Phase 5 — Reports & compliance (3 days)
- Add
channelSourcegroup-by to every existing report. - Regulator aggregates split by channel.
- Full audit vocabulary rolled out.
Total: ~4 weeks assuming one full-time engineer.