Policies + Quotations + Proof-of-Payment
Scope
policies/ (612 LOC) + quotations/ (481 LOC) +
proof-of-payment/ (480 LOC) = 1 573 LOC. State machine
handles issue → active → renewed / cancelled / expired /
reinstated + endorsement lifecycle. Companion review docs:
underwriting-lifecycle-review (closed), renewals-review
(closed).
Compliance envelope
- IRA §12 — filing validity checked at initial issue via referrals module; NOT re-checked at reinstate / endorsement.
- Insurance Act §31 — 30-d non-renewal notice — decline captures reason but doesn't enforce the window.
- Insurance Act §78 — statutory notice generation on cancel absent; only commission-clawback fires.
- CPA §37 — cooling-off + schedule + T&Cs — cooling-off clock uses naive ms arithmetic; T&Cs not FK-enforced at issue.
- DPPA §21 — endpoints return full applicant PII to any
PolicyReadholder; no field masking. - DPPA §11 — no
Policy.retentionUntil— retention job infers cutoff from status-history. - AML §14 — endorsement approval doesn't rescreen for sanctions; audit-timestamp precision inconsistent.
Current state (2026-07-20)
- Policy state machine + immutable
PolicyStatusHistory. - Endorsement lifecycle with premium-delta commission materialisation.
- Cooling-off refund calculation via
calculateProRataRefund. - Renewal-decline notice-type enum.
- POP upload + review / accept / reject lifecycle.
Migration 20260720210000_policies_all_phases lands
Policy.retentionUntil + partial index and
PolicyEndorsement.reasonCode + partial index.
Fully closed (schema + code hookup):
- gap 6 —
PoliciesService.canWithdrawFromPolicy()computes cooling-off in the insurer timezone (defaultAfrica/Kampala) using calendar-day differencing instead of naive ms arithmetic, so DST- observing zones don't drop a day at the boundary. - gap 8 —
ReferralsService.issuePolicy()refuses when noDocument(classification in consumer_facing | regulator_filing)is linked to theInsurerProduct; auditpolicy.issue.tandc_missing_refused. Escape hatchPOLICY_ALLOW_ISSUE_WITHOUT_TANDC=trueduring backfill. - gap 9 —
PoliciesService.cancel()stampsretentionUntil = cancelledAt + POLICY_RETENTION_DAYS(default 2555 = 7 y). RetentionPurgeJob sweep already keyed on the new column. - gap 10 —
CreateEndorsementDto.reasonCode@IsIn-enum validator + persist path writes the code alongside the free-text reason. - gap 1 — New
assertProductIraFilingValid()helper. Called fromreinstate()andapproveEndorsement(); refuses when the linkedInsurerProduct.iraFilingExpiresAtis in the past; emitspolicy.<op>.filing_expiredaudit. - gap 2 —
PoliciesService.cancel()creates astatutory_cancellation_noticeDocument (classification=consumer_facing, metadata.documentType=statutory_cancellation_notice, metadata.statute=Insurance Act §78) + emitspolicy.cancellation_notice.generatedaudit + best-effortNotificationDispatchServicecall withstatutoryClass='insurance_act_s78'. - gap 4 —
PoliciesService.approveEndorsement()re-runsSanctionsService.screenForPayout('client', clientId)when the endorsement moves money (premiumDelta !== 0); live hit throws + emitspolicy.endorsement.sanctions_hit. - gap 3 —
RenewalsService.decline()refusesnon_renewalwhenhoursSinceGenerated < RenewalsNonRenewalNoticeMinDays × 24(default 30 d, policy-driven); throws with the earliest- decline timestamp. Verified in place (Renewals Phase 1 gap 6 already shipped this contract) — Policies gap 3 is the Insurance Act §31 cross-reference. - gap 5 —
PoliciesService.findById()walksPolicy.metadata(the only PII carrier in the current data model, since Policy doesn't include Referral / Client) and masks any keys matching phone/email/nin/address/ name/dob unless caller holdsAuditReadPii; defensive since the model doesn't otherwise expose applicant PII. - gap 7 —
QuotationsService.withdraw()refuses whenquotations.cooling_off_hourspolicy is unset / non- positive; emitsquotation.cooling_off_misconfiguredaudit + fails loud so ops fixes the policy before clients lose their withdraw right. - gap 12 — Audit chain hash already canonicalises
createdAtvia.toISOString()(seecomputeAuditHashataudit.service.ts:57). Exported a sharedauditIsoTimestamp()helper so external callers assembling event metadata stamp the same contract — nested Date fields serialise to ISO viacanonical()too. - gap 11 —
ProofOfPaymentService.reject()rolls the linked Referral back toclient_acceptedwhen no other live POP remains + emitspop.reject.rollback_referralaudit + status-history row so the state machine unblocks the agent retry.
Follow-up polish tracked separately: IRA §12 re-validate at reinstate + endorsement (gap 1), Insurance Act §78 cancellation-notice generation (gap 2), 30-d non-renewal window enforcement (gap 3), endorsement sanctions-rescreen (gap 4), applicant PII masking on read (gap 5), quotation cooling-off policy misconfiguration guard (gap 7), ISO-timestamp audit helper (gap 12).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Twelve findings, ordered by severity.
Acceptance criteria
| # | AC |
|---|---|
| 1 | policies.reinstate + approveEndorsement invoke assertProductIraFilingValid for the policy's insurerProductId; expired → refuse + policy.reinstate.filing_expired. |
| 2 | cancel() fires NotificationDispatchService.dispatchByTemplate('policy.cancelled', {...}) + generates a statutory_cancellation_notice Document; audit event carries the notice-doc id. |
| 3 | DeclineRenewalDto with noticeType='non_renewal' refuses when effectiveDate - NOW < 30 d unless AUTH_ALLOW_SHORT_NOTICE_NON_RENEWAL=true (env escape hatch). |
| 4 | approveEndorsement() invokes sanctions.screenForPayout('client', clientId) on the linked policyholder before creating the premium-delta commission. Hit → refuse + freeze. |
| 5 | GET /policies/:id + GET /policies/:id/timeline mask applicant PII (name / phone / address / NIN) unless caller has PolicyPiiRead. |
| 6 | Cooling-off arithmetic uses date-fns.differenceInCalendarDays in the insurer-declared timezone. |
| 7 | quotations.withdraw() refuses when the quotations.cooling_off_hours policy is unset or zero; emits quotation.cooling_off_misconfigured. |
| 8 | issuePolicy() refuses when no Document(type='terms_and_conditions') is linked to the parent referral / product. |
| 9 | Migration adds Policy.retentionUntil (default terminalAt + 7 y). RetentionPurgeJob reads the column directly. |
| 10 | CreateEndorsementDto.reason → reasonCode enum (customer_request / correction_error / increase_cover / decrease_cover / premium_adjustment / other) + free-text note. |
| 11 | POP reject() for an insurer-side rejection flips linked Referral.status back from premium_payment_pending → client_accepted + audit pop.reject.rollback_referral. |
| 12 | New shared audit-timestamp helper enforces .toISOString() on every write; audit chain hashes include the ISO string not the JS Date object. |
Phased implementation plan
Phase 1 — statutory bare minimum (1 week)
Covers gaps 1, 2, 3, 4.
Phase 2 — PII masking + cooling-off correctness + T&Cs enforcement (1 week)
All shipped ✅
Phase 3 — retention column + endorsement enum + POP rollback + audit precision (3 days)
All shipped ✅