Skip to main content

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

  1. 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.
  2. Nullable agentId. The referral schema flips agentId from required to optional. Existing agent-mediated referrals stay the default; the direct channel simply omits it.
  3. 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.
  4. 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.
  5. 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 Client model with its own lifecycle.
  6. 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-flowEntry pointClient identityOwner
Public self-serviceAnonymous form on the marketing site → POST /public/client-inquiriesCreated lazily on first response, magic-link login sent by emailZFA operations queue
Portal-drivenClient already has a portal session (previous policyholder, renewed via OTP) → POST /client-portal/inquiriesExisting ClientPortalSessionSame client, own record
ZFA-mediatedWalk-in / phone call — ZFA staff creates on behalf → POST /admin/zfa-direct/inquiriesZFA staff selects/creates clientZFA 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 — PiiCipher for encryption, PiiCipherHmac for the search hash.
  • The code follows the same generator pattern as agent codes (CLI-<year>-<random>).
  • Backfill for existing agent-mediated referrals: no Client row is created retroactively. Only referrals with a clientId FK 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:own and zfa_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)

MethodPathPurpose
POST/public/client-inquiriesAnonymous inquiry submission. Rate-limited 5/min/IP. Triggers a magic-link email to the supplied address.
POST/public/client-inquiries/:id/claimClient clicks the magic-link → verifies email → gets an OTP → verifies OTP → issued a ClientPortalSession linked to a lazily-created Client.

Client portal (session-guarded)

MethodPathPurpose
GET/client-portal/me/inquiriesList of the client's own inquiries.
POST/client-portal/inquiriesCreate a new inquiry as an authenticated portal client (returning customer).
GET/client-portal/me/referralsReferrals derived from the client's inquiries.
POST/client-portal/referrals/:id/popUpload proof-of-payment directly (bypasses agent).

Admin / ZFA-direct desk

MethodPathPurpose
POST/admin/zfa-direct/inquiriesZFA staff creates on behalf of a walk-in client.
POST/admin/zfa-direct/inquiries/:id/assignAssign to a specific ZFA handler.
POST/admin/zfa-direct/inquiries/:id/convert-to-referralConvert 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/walletRead the house wallet ledger.
POST/admin/zfa-house/wallet/withdrawMove funds out of the house wallet (internal reconciliation with ZFA's operating account).
GET/admin/clientsList / search the new first-class Client entities.
GET/admin/clients/:idClient 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 zfaDirectHandlerId the same way it treats agentId for scoping: staff see their own direct-channel referrals via zfa_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:
    1. ReferralChannelSource enum, Referral.channelSource column defaulting to agent_referral (backfill all existing rows).
    2. Referral.agentId → nullable.
    3. Referral.zfaDirectHandlerId, Referral.clientId — nullable FKs.
    4. Client table + WalletOwnerType enum + Wallet.ownerType column defaulting to agent.
    5. DeductionType.beneficiaryTypes column defaulting to ["agent"].
  • Seed: one zfa_house wallet 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.
  • Client CRUD (list, get, create, update).
  • Basic /reports/zfa-direct.

Phase 3 — Portal (1 week)

  • Extend the existing client portal:
    • GET /client-portal/me/inquiries
    • POST /client-portal/inquiries
    • GET /client-portal/me/referrals
    • POST /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 beneficiaryTypes gate.
  • 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 channelSource group-by to every existing report.
  • Regulator aggregates split by channel.
  • Full audit vocabulary rolled out.

Total: ~4 weeks assuming one full-time engineer.


Open questions

  1. Client identity dedupe — a walk-in whose phone number already maps to an agent-mediated referral. Do we auto-link them to that Client record (creating it lazily) or keep them separate? Suggest auto-link via phoneHash uniqueness constraint.
  2. Agents introducing house clients — an edge case: agent gives a client ZFA's direct number instead of doing the referral themselves. Do we attribute a partial commission to the "referring" agent? Suggest no — direct-channel means no agent leg. Handle this commercially via the agent's tier / KPI review, not in code.
  3. Multi-channel over time — a client who started direct then later engaged an agent. Preserve Client.channelSource as their origin channel, but each Referral carries its own channelSource. Reports read per-referral, not per-client.
  4. Insurer webhooks — direct-channel referrals still flow to insurers. Confirm no insurer contract requires an agent id in the outbound webhook body — if any do, populate agentId with a synthetic ZFA-HOUSE agent code the insurer expects.
  5. Deposit protection — the platform doesn't take client money today (see flow doc principle 1). Confirm the direct channel does not change this. If ZFA ever accepts client deposits, that's a regulated change and needs a separate design pass with the IRA.

What this doc does not cover

  • UI/UX for the client portal, admin desk, or public landing page — separate frontend design docs.
  • CRM integrations — the inquiry flow assumes ZFA staff work inside InsureLink. If ZFA runs a separate CRM (HubSpot, Zoho, etc.), the inquiry surface needs webhook bridges — call that out as a Phase 6.
  • Existing agent commissions — they are untouched. Agents keep their wallets, their tier progression, their sponsor tree, their bordereau reporting.

Next step

Once this design is approved, land Phase 1 as a single PR:

  • migration 20260714_zfa_direct_channel/migration.sql
  • schema changes (Referral, Wallet, DeductionType, new Client model, new enum)
  • permissions + seed updates
  • ObjectAuthorizationService extension
  • endpoint matrix regenerates automatically to show zfa_direct:* permissions on the new routes

Phases 2–5 land as separate PRs after Phase 1 is deployed. Each phase is independently valuable: Phase 2 alone unlocks the walk-in desk; Phase 3 unlocks self-service; Phase 4 finalises the money side.