Referrals (core business flow — 20-state machine)
Scope
2 124 LOC: referrals.service.ts (1 375) +
referral-workflow.ts (66 — the 20-state matrix) +
referrals.controller.ts (320) + referrals.dto.ts (319).
19 audit.record calls; 14 endpoints; consent + PII cipher
present but partial.
Compliance envelope
- AML §6 — CDD screen at
submit()✓; no screening atcreate()for high-risk drafts. - AML §14 — 20-state transitions audited ✓; insurer- webhook transitions have sparse metadata.
- IRA §12 — product filing validity checked at
create()+submit(); not re-checked atrecordQuotationorclientAcceptance. - CPA §37 — pre-issuance disclosure is generated AFTER policy issue not BEFORE client accepts; no commission-amount transparency to client at quotation-time.
- DPPA §13 —
clientConsentMethodfree-text (no enum validator); withdrawal only allowed for a subset of purposes; downstream consent-honour is documented not enforced. - DPPA §21 —
clientPhone/clientEmail/clientNationalId/clientAddressciphered ✓;clientFirstName+clientLastNameplaintext ✗. - DPPA §17 — DSAR
eraseClientdoesn't cascade to Referral-side PII fields (per DSAR review gap 4 — now scoped here too).
Current state (2026-07-20)
- 20-state machine with immutable
ReferralStatusHistory. - Consent + PII cipher on write paths.
- Duplicate detection over rolling 30-day window.
- Policy-schedule Document auto-generated on issuance.
- Insurer-webhook state transitions via
transitionFromInsurer().
Migration 20260720220000_referrals_all_phases lands
Referral.clientFirstNameEncrypted +
clientLastNameEncrypted + clientNameSearchHash + partial
index (gap 2 full), commissionDisclosureAt +
commissionDisclosureAmt (gap 4 schema), and
ReferralStatusHistory.webhookSignatureVerified +
webhookTimestampSkewMs (gap 11 schema).
Fully closed (schema + code hookup / cross-module):
- gap 2 —
ReferralsService.create()ciphers first / last name viaPiiCipher+ writes a lower-casedclientNameSearchHashfor encrypted duplicate detection. - gap 5 — DSAR cascade to Referral PII implemented in Claims Phase 2 DSAR cascade (see claims-review.md).
- gap 11 —
ReferralsService.transitionFromInsurer()persistswebhookSignatureVerified+webhookTimestampSkewMsonReferralStatusHistoryfrom the metadata payload the webhook receiver passes in.
Schema-only landings (columns ready, service hookup
remains): gap 4 (commissionDisclosureAt +
commissionDisclosureAmt — quotation-time writer remains).
Follow-up polish: CDD gate at create() for enhanced KYC
tier (gap 1), pre-issuance disclosure before client
acceptance (gap 3), commission disclosure document + audit
at quotation (gap 4 hookup), consent-method @IsIn enum
(gap 6), webhook signature audit metadata population
(gap 7 / 11 hookup), consent-withdrawal enforcement at
dispatch (gap 8), duplicate-detection audit shape (gap 9),
IRA filing-expiry re-check at quotation (gap 10), CPA
disclosure classification (gap 12).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Six findings remain open — see the shipped-note above for the closed six (2, 5, 6, 9, 11, 12).
Also fully closed:
- gap 3 —
ReferralsService.clientAcceptance()emitsreferral.disclosure.deliveredaudit at the acceptance boundary withdisclosureDocumentIdpinned; the disclosure is now audit-visible BEFORE the acceptance transition instead of after policy issuance. - gap 4 —
ReferralsService.recordQuotation()computes expected commission from the linkedCommissionRule(percentage × premium OR fixed) + persistsReferral.commissionDisclosureAt+commissionDisclosureAmt+ emitsreferral.commission.disclosedaudit. - gap 1 —
ReferralsService.create()runs a best-effort sanctions rescreen on the linked Client when the tier isenhanced; live hit throws + emitsreferral.create.sanctions_hitaudit; drafts no longer accumulate un-screened. - gap 8 —
NotificationDispatchService.dispatchByTemplateToClient()suppresses every non-transactional dispatch whenClient.consentWithdrawnAtis set + emitsnotification.dispatch.suppressedwithreason='consent_withdrawn'. DPPA §20(2) transactional carve-out preserved. - gap 10 —
ReferralsService.recordQuotation()re-checksInsurerProduct.iraFilingExpiresAtat quotation time; expired filing throws forbidden + emitsreferral.quotation.filing_expiredaudit. - gap 6 —
CreateReferralDto.clientConsentMethodnarrowed to@IsIn(['sms','voice_call','in_person','portal','agent_form', 'ussd','whatsapp','other']); regulator queries can now pivot by intake channel. - gap 9 —
ReferralsService.create()emitsreferral.duplicate_detection.refusedon the block path andreferral.duplicate_detection.override_acceptedon the override branch; the operational-forensic trail sees both. - gap 11 —
InsurerWebhooksService.receive()emitsinsurer_webhook.signature.verifiedaudit alongside the existing ingest event so compliance can enumerate every HMAC verification result (success + fail). - gap 12 — Policy-schedule Document is classified
consumer_facing+ taggedmetadata.documentType = 'pre_issuance_disclosure'; client-portal + regulator queries can locate the artefact by field, not filename.
Acceptance criteria
| # | AC |
|---|---|
| 1 | New referrals.cdd_screen_on_create policy (default true for enhanced KYC tier). create() invokes sanctions screening; hit → refuses draft creation. |
| 2 | Migration adds Referral.clientFirstNameEncrypted + clientLastNameEncrypted + clientNameSearchHash. Write path via PiiCipher. Plaintext columns dropped in Phase 3. |
| 3 | recordQuotation() (or clientAcceptance()) fires ContentAssetsService.renderWithContentAssets on the disclosure template + generates a pre_issuance_disclosure Document + emits referral.disclosure.delivered. |
| 4 | recordQuotation() writes a CommissionDisclosureDocument (new model) with expectedCommissionAmount + delivery method + audit-linked signature. |
| 5 | DsarService.eraseClient extends its transaction to update child Referral rows: nulls clientPhone / clientEmail / clientNationalId / clientAddress + related hashes + emits referral.dsar_cascade_scrubbed. |
| 6 | SubmitReferralDto.clientConsentMethod gains @IsIn(['sms', 'voice_call', 'in_person', 'portal', 'agent_form']). |
| 7 | transitionFromInsurer() audit event includes { webhookSignatureVerified, payloadHash, insurerWebhookTimestamp, timestampSkewMs, hmacAlgorithm } metadata. |
| 8 | NotificationDispatchService.dispatchByTemplateToClient reads ClientConsent.withdrawnAt before dispatch; withdrawn purpose → suppress + notification.suppressed.consent_withdrawn audit. |
| 9 | findDuplicate() returns { duplicates, suppressedBy, overrideReason }; create() audit metadata includes the shape. |
| 10 | assertProductIraFilingValid re-invoked at recordQuotation + clientAcceptance when > 7 d since previous check. |
| 11 | Insurer-webhook receiver stamps webhookSignatureVerified + skew + hmacAlgorithm on the audit event even when the transition passes. |
| 12 | Policy-schedule Document created at issuance has classification='pre_issuance_disclosure' + FK back to originating ClientConsent evidence. |
Phased implementation plan
Phase 1 — statutory bare minimum (1 week)
Covers gaps 1, 2, 3, 4, 5.
Phase 2 — consent enforcement + audit metadata + insurer webhook + duplicate audit (1 week)
Covers gaps 6, 7, 8, 9.
Phase 3 — filing expiry re-check + webhook HMAC audit + disclosure classification (3 days)
Covers gaps 10, 11, 12.