Skip to main content

Renewals

Phase 1 shipped (2026-07-17)

The four statutory criticals — gaps 1, 2, 4, 6 — landed in release renewals-p1-20260717215413.

  • gap 1: new SystemPermissions.ReferralAutoRenewConsent + POST /referrals/:id/consent/auto-renew accepting { consented: boolean, consentSource? }. Toggles Referral.autoRenewalConsented + autoRenewalConsentedAt and emits referral.auto_renew.consent (or referral.auto_renew.consent_withdrawn) with the source channel stamped on the audit metadata. CPA §37 + DPPA §11 affirmative-consent record is now first-class; the RenewalGenerationJob cron filter can start finding non-zero candidates.
  • gap 2: new SystemPermissions.RenewalAccept + POST /renewals/:id/accept. Transitions offered → accepted, writes new RenewalQuote.acceptedAt column, emits renewal.accept audit event. Agent-scoped: caller must own the referral's agent unless they hold the compliance-wide RenewalConvert. Anchor timestamp for Phase 2's cooling-off window.
  • gap 4: RenewalsService.convert() now calls SanctionsService.screenReferralIntake() with the policyholder's identity before minting the fresh referral. A potential_match / confirmed_match refuses convert with 403 + emits renewal.convert.sanctions_block; the clean screening id is stamped on the new referral's metadata.sanctionsScreeningId so the AML §6 CDD-at-rebind trail is queryable.
  • gap 6: decline() gained a noticeType DTO field (non_renewal / agent_withdrawal / insurer_capacity). New renewals.non_renewal_notice_min_days policy (default 30 days). non_renewal declines inside the window raise INVARIANT_VIOLATION with the earliest-allowed decline date; other notice types bypass the check. New audit action renewal.non_renewal_notice fires on statutory-window declines (regular declines keep the existing renewal.decline action). Notice type is persisted on the row + inside metadata.

Schema migration 20260717213000_renewals_phase1 adds RenewalQuote.acceptedAt + RenewalQuote.noticeType + supporting indexes.

New renewals-phase1.spec locks 8 contracts (accept happy-path + non-owning-agent 403 + wrong-status invariant, non-renewal window in-and-out + agent-withdrawal exemption, sanctions-clear convert + sanctions-block refuse). 59/59 suites, 436/436 tests green.

Phase 2 shipped (2026-07-17)

The consumer-protection + integrity block — gaps 3, 5, 7, 12 — landed in release renewals-p2-20260717222933.

  • gap 3: POST /renewals/:id/withdraw (requires RenewalAccept, agent-scoped to the referral's agent) allows cooling-off withdrawal inside renewals.cooling_off_hours policy (default 168 h / 7 days) after acceptedAt. New withdrawn terminal enum value + withdrawnAt + withdrawnReason columns. Emits renewal.withdraw audit event.
  • gap 5: new RenewalQuote.validTo column. offer() accepts an optional validTo on the DTO; otherwise defaults to proposedEffectiveFrom - 14 d. accept() refuses when validTo < now and instructs the client to request a fresh quote — no more silent divergence between platform-side quote validity and insurer-side offer validity.
  • gap 7: new RenewalQuote.noticeDeliveries JSON array (default []). RenewalGenerationJob now calls RenewalsService.recordNoticeDelivery() after each dispatchByTemplateToClient() — records { channel, dispatchedAt, notificationId, deliveryStatus } per attempt (with 'queued' initially, 'failed' on exception). Notifications- dispatch delivery webhooks can back-fill deliveryStatus to delivered / bounced / undeliverable later. Insurance Act §31 "prove notice was sent" is now a single-column read.
  • gap 12: new RenewalMetadataDto (offerMessage?, declineReason?, noticeType?, insurerReference?, sanctionsScreeningId?) validated with class-validator. offer() merges the DTO onto the row; unknown keys land as 400. WithdrawRenewalDto also validated. Same shape of guard as Regulator Reports Phase 2 gap 8.

Schema migration 20260717220000_renewals_phase2 adds RenewalQuote.withdrawnAt, withdrawnReason, validTo, noticeDeliveries, and extends RenewalQuoteStatus with withdrawn.

New renewals-phase2.spec locks 7 additional contracts (withdrawal in-window / out-of-window / wrong-status / non-owner, validTo default + accept refusal, noticeDeliveries append). 60/60 suites, 443/443 tests green.

Phase 3 shipped (2026-07-17)

The hygiene + retention block — gaps 8, 9, 10, 11 — landed in release renewals-p3-20260717224021, closing the module at 12 / 12 gaps.

  • gap 8: Idempotency-Key accepted on POST /renewals/:id/{offer,decline,accept,withdraw,convert} via thin service-side wrappers using the shared IdempotencyService (24 h TTL, per-endpoint scope). Same body under the same key replays the memoised response; body-mismatch → 409 IDEMPOTENCY_CONFLICT.
  • gap 9: @Throttle decorators across the whole surface — mutation endpoints (offer, decline, accept, withdraw, convert): 20/min per caller; generate: 10/min; list + findById: 60/min. Excess → 429.
  • gap 10: new data_sharing.renewals_retention_days policy (default 2555 = 7 y). RetentionPurgeJob extends across RenewalQuote — terminal statuses (declined, lapsed, converted, withdrawn) past the window get metadata, withdrawnReason, noticeDeliveries scrubbed + retentionScrubbedAt stamped. Emits an aggregate renewal.retention.scrubbed audit event.
  • gap 11: RenewalsService.findById() now mirrors list()'s ownership rule. Callers without RenewalConvert (compliance-wide) must own the referral's agent or receive 403. Legacy internal callers not passing user (specs, seeds) bypass the guard for back-compat.

Schema migration 20260717223500_renewals_phase3 adds RenewalQuote.retentionScrubbedAt + supporting index.

New renewals-phase3.spec locks 4 additional contracts (owning-agent / non-owning-agent / compliance / legacy-noscope findById paths). 61/61 suites, 447/447 tests green.

All 12 / 12 gaps closed for the Renewals module.

Scope

One module that drives the end-of-term policy lifecycle:

  • renewals/RenewalQuote lifecycle. pending → offered → accepted | declined | lapsed → converted. Two cron drivers: RenewalGenerationJob walks Policy.effectiveTo at 60 / 30 / 7 days out and creates the quote + fans out a renewal.notice.customer notification; RenewalSweepJob performs a duplicate-generation pass + laps offered quotes past their proposedEffectiveFrom boundary.

The renewal flow ends by minting a fresh Referral linked back to the source policy via metadata.renewalOfPolicyId; from there the standard Referral → Quotation → Underwriting → Policy lifecycle resumes (see the underwriting-lifecycle review for that chain).

Compliance envelope

  • IRA Insurance Act 2017 §31 — non-renewal requires written notice to the policyholder within a statutory window (typically 30–60 days before expiry). The notice must state the reason and advise on grievance rights.
  • CPA 2011 §37 — 7-day cooling-off on any renewed consumer financial contract. A client who accepts a renewal has an unconditional right to withdraw inside the window.
  • DPPA 2019 §11 — automated decisions "significantly affecting" the data subject (auto-renewal is one) require prior specific consent + a challenge path.
  • AML Act 2013 §6 — CDD obligation runs to every policy binding, not just first-time onboarding; a re-screen is required when a renewal converts to a fresh referral / policy.
  • DPPA 2019 §17 — right to erasure applies to renewal-quote ledger past the statutory retention floor (AML §14 + URA §41 7-year window).
  • BOU Cybersecurity Guidelines §5.4 — every state transition + every notice-of-renewal delivery attempt audit-logged.

Current state (2026-07-17)

Module footprint

src/modules/renewals/renewals.module.ts — 311 LOC
src/modules/scheduled-jobs/renewal-generation.job.ts — daily 03:30 UTC
src/modules/scheduled-jobs/renewal-sweep.job.ts — daily 05:00 UTC

Prisma models: RenewalQuote, Policy (parent), Referral (child of convert).

What works today

  • Windowed quote generation: RENEWAL_WINDOWS = [60, 30, 7] drives renewal-generation.job — a Policy with effectiveTo falling inside one of the windows gets a pending RenewalQuote
    • a fan-out via NotificationDispatchService.dispatchByTemplate (renewal.notice.customer).
  • Explicit lifecycle transitions: offer() / decline() write structured audit events per state change.
  • Convert-to-referral: convert() mints a Referral in draft linked back via metadata.renewalOfPolicyId + renewalQuoteId, so the downstream underwriting chain has full provenance.
  • Idempotent generation: renewal-generation.job re-runs are safe — quote creation is upserted on (policyId, proposedEffectiveFrom). renewal-sweep.job performs the same generation pass so a missed cron doesn't lose a window.
  • Agent-scoped list: list() restricts callers without RenewalRead (org-wide) to their own agent's referrals.

Gaps

All 12 gaps closed across Phase 1, Phase 2, and Phase 3 (all shipped 2026-07-17). See the shipped-notes above for the exact scope of each phase; every gap is now enforced by locked spec coverage in renewals-phase1.spec / renewals-phase2.spec / renewals-phase3.spec.