Skip to main content

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 at create() 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 at recordQuotation or clientAcceptance.
  • CPA §37 — pre-issuance disclosure is generated AFTER policy issue not BEFORE client accepts; no commission-amount transparency to client at quotation-time.
  • DPPA §13clientConsentMethod free-text (no enum validator); withdrawal only allowed for a subset of purposes; downstream consent-honour is documented not enforced.
  • DPPA §21clientPhone / clientEmail / clientNationalId / clientAddress ciphered ✓; clientFirstName + clientLastName plaintext ✗.
  • DPPA §17 — DSAR eraseClient doesn'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().

Partial ship — 2026-07-20 (ref-20260720-1302 + wire1)

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 via PiiCipher + writes a lower-cased clientNameSearchHash for 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() persists webhookSignatureVerified + webhookTimestampSkewMs on ReferralStatusHistory from 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() emits referral.disclosure.delivered audit at the acceptance boundary with disclosureDocumentId pinned; the disclosure is now audit-visible BEFORE the acceptance transition instead of after policy issuance.
  • gap 4 — ReferralsService.recordQuotation() computes expected commission from the linked CommissionRule (percentage × premium OR fixed) + persists Referral.commissionDisclosureAt + commissionDisclosureAmt + emits referral.commission.disclosed audit.
  • gap 1 — ReferralsService.create() runs a best-effort sanctions rescreen on the linked Client when the tier is enhanced; live hit throws + emits referral.create.sanctions_hit audit; drafts no longer accumulate un-screened.
  • gap 8 — NotificationDispatchService.dispatchByTemplateToClient() suppresses every non-transactional dispatch when Client.consentWithdrawnAt is set + emits notification.dispatch.suppressed with reason='consent_withdrawn'. DPPA §20(2) transactional carve-out preserved.
  • gap 10 — ReferralsService.recordQuotation() re-checks InsurerProduct.iraFilingExpiresAt at quotation time; expired filing throws forbidden + emits referral.quotation.filing_expired audit.
  • gap 6 — CreateReferralDto.clientConsentMethod narrowed 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() emits referral.duplicate_detection.refused on the block path and referral.duplicate_detection.override_accepted on the override branch; the operational-forensic trail sees both.
  • gap 11 — InsurerWebhooksService.receive() emits insurer_webhook.signature.verified audit alongside the existing ingest event so compliance can enumerate every HMAC verification result (success + fail).
  • gap 12 — Policy-schedule Document is classified consumer_facing + tagged metadata.documentType = 'pre_issuance_disclosure'; client-portal + regulator queries can locate the artefact by field, not filename.

Acceptance criteria

#AC
1New referrals.cdd_screen_on_create policy (default true for enhanced KYC tier). create() invokes sanctions screening; hit → refuses draft creation.
2Migration adds Referral.clientFirstNameEncrypted + clientLastNameEncrypted + clientNameSearchHash. Write path via PiiCipher. Plaintext columns dropped in Phase 3.
3recordQuotation() (or clientAcceptance()) fires ContentAssetsService.renderWithContentAssets on the disclosure template + generates a pre_issuance_disclosure Document + emits referral.disclosure.delivered.
4recordQuotation() writes a CommissionDisclosureDocument (new model) with expectedCommissionAmount + delivery method + audit-linked signature.
5DsarService.eraseClient extends its transaction to update child Referral rows: nulls clientPhone / clientEmail / clientNationalId / clientAddress + related hashes + emits referral.dsar_cascade_scrubbed.
6SubmitReferralDto.clientConsentMethod gains @IsIn(['sms', 'voice_call', 'in_person', 'portal', 'agent_form']).
7transitionFromInsurer() audit event includes { webhookSignatureVerified, payloadHash, insurerWebhookTimestamp, timestampSkewMs, hmacAlgorithm } metadata.
8NotificationDispatchService.dispatchByTemplateToClient reads ClientConsent.withdrawnAt before dispatch; withdrawn purpose → suppress + notification.suppressed.consent_withdrawn audit.
9findDuplicate() returns { duplicates, suppressedBy, overrideReason }; create() audit metadata includes the shape.
10assertProductIraFilingValid re-invoked at recordQuotation + clientAcceptance when > 7 d since previous check.
11Insurer-webhook receiver stamps webhookSignatureVerified + skew + hmacAlgorithm on the audit event even when the transition passes.
12Policy-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.

Covers gaps 6, 7, 8, 9.

Phase 3 — filing expiry re-check + webhook HMAC audit + disclosure classification (3 days)

Covers gaps 10, 11, 12.