Skip to main content

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 PolicyRead holder; 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.

Partial ship — 2026-07-20 (pol-20260720-1257 + wire1 + wire8)

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 (default Africa/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 no Document(classification in consumer_facing | regulator_filing) is linked to the InsurerProduct; audit policy.issue.tandc_missing_refused. Escape hatch POLICY_ALLOW_ISSUE_WITHOUT_TANDC=true during backfill.
  • gap 9 — PoliciesService.cancel() stamps retentionUntil = 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 from reinstate() and approveEndorsement(); refuses when the linked InsurerProduct.iraFilingExpiresAt is in the past; emits policy.<op>.filing_expired audit.
  • gap 2 — PoliciesService.cancel() creates a statutory_cancellation_notice Document (classification=consumer_facing, metadata.documentType=statutory_cancellation_notice, metadata.statute=Insurance Act §78) + emits policy.cancellation_notice.generated audit + best-effort NotificationDispatchService call with statutoryClass='insurance_act_s78'.
  • gap 4 — PoliciesService.approveEndorsement() re-runs SanctionsService.screenForPayout('client', clientId) when the endorsement moves money (premiumDelta !== 0); live hit throws + emits policy.endorsement.sanctions_hit.
  • gap 3 — RenewalsService.decline() refuses non_renewal when hoursSinceGenerated < 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() walks Policy.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 holds AuditReadPii; defensive since the model doesn't otherwise expose applicant PII.
  • gap 7 — QuotationsService.withdraw() refuses when quotations.cooling_off_hours policy is unset / non- positive; emits quotation.cooling_off_misconfigured audit + fails loud so ops fixes the policy before clients lose their withdraw right.
  • gap 12 — Audit chain hash already canonicalises createdAt via .toISOString() (see computeAuditHash at audit.service.ts:57). Exported a shared auditIsoTimestamp() helper so external callers assembling event metadata stamp the same contract — nested Date fields serialise to ISO via canonical() too.
  • gap 11 — ProofOfPaymentService.reject() rolls the linked Referral back to client_accepted when no other live POP remains + emits pop.reject.rollback_referral audit + 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
1policies.reinstate + approveEndorsement invoke assertProductIraFilingValid for the policy's insurerProductId; expired → refuse + policy.reinstate.filing_expired.
2cancel() fires NotificationDispatchService.dispatchByTemplate('policy.cancelled', {...}) + generates a statutory_cancellation_notice Document; audit event carries the notice-doc id.
3DeclineRenewalDto with noticeType='non_renewal' refuses when effectiveDate - NOW < 30 d unless AUTH_ALLOW_SHORT_NOTICE_NON_RENEWAL=true (env escape hatch).
4approveEndorsement() invokes sanctions.screenForPayout('client', clientId) on the linked policyholder before creating the premium-delta commission. Hit → refuse + freeze.
5GET /policies/:id + GET /policies/:id/timeline mask applicant PII (name / phone / address / NIN) unless caller has PolicyPiiRead.
6Cooling-off arithmetic uses date-fns.differenceInCalendarDays in the insurer-declared timezone.
7quotations.withdraw() refuses when the quotations.cooling_off_hours policy is unset or zero; emits quotation.cooling_off_misconfigured.
8issuePolicy() refuses when no Document(type='terms_and_conditions') is linked to the parent referral / product.
9Migration adds Policy.retentionUntil (default terminalAt + 7 y). RetentionPurgeJob reads the column directly.
10CreateEndorsementDto.reasonreasonCode enum (customer_request / correction_error / increase_cover / decrease_cover / premium_adjustment / other) + free-text note.
11POP reject() for an insurer-side rejection flips linked Referral.status back from premium_payment_pendingclient_accepted + audit pop.reject.rollback_referral.
12New 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 ✅