Renewals
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-renewaccepting{ consented: boolean, consentSource? }. TogglesReferral.autoRenewalConsented+autoRenewalConsentedAtand emitsreferral.auto_renew.consent(orreferral.auto_renew.consent_withdrawn) with the source channel stamped on the audit metadata. CPA §37 + DPPA §11 affirmative-consent record is now first-class; theRenewalGenerationJobcron filter can start finding non-zero candidates. - gap 2: new
SystemPermissions.RenewalAccept+POST /renewals/:id/accept. Transitionsoffered → accepted, writes newRenewalQuote.acceptedAtcolumn, emitsrenewal.acceptaudit event. Agent-scoped: caller must own the referral's agent unless they hold the compliance-wideRenewalConvert. Anchor timestamp for Phase 2's cooling-off window. - gap 4:
RenewalsService.convert()now callsSanctionsService.screenReferralIntake()with the policyholder's identity before minting the fresh referral. Apotential_match/confirmed_matchrefuses convert with 403 + emitsrenewal.convert.sanctions_block; the clean screening id is stamped on the new referral'smetadata.sanctionsScreeningIdso the AML §6 CDD-at-rebind trail is queryable. - gap 6:
decline()gained anoticeTypeDTO field (non_renewal/agent_withdrawal/insurer_capacity). Newrenewals.non_renewal_notice_min_dayspolicy (default 30 days).non_renewaldeclines inside the window raiseINVARIANT_VIOLATIONwith the earliest-allowed decline date; other notice types bypass the check. New audit actionrenewal.non_renewal_noticefires on statutory-window declines (regular declines keep the existingrenewal.declineaction). Notice type is persisted on the row + insidemetadata.
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.
The consumer-protection + integrity block — gaps 3, 5, 7,
12 — landed in release renewals-p2-20260717222933.
- gap 3:
POST /renewals/:id/withdraw(requiresRenewalAccept, agent-scoped to the referral's agent) allows cooling-off withdrawal insiderenewals.cooling_off_hourspolicy (default 168 h / 7 days) afteracceptedAt. Newwithdrawnterminal enum value +withdrawnAt+withdrawnReasoncolumns. Emitsrenewal.withdrawaudit event. - gap 5: new
RenewalQuote.validTocolumn.offer()accepts an optionalvalidToon the DTO; otherwise defaults toproposedEffectiveFrom - 14 d.accept()refuses whenvalidTo < nowand 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.noticeDeliveriesJSON array (default[]).RenewalGenerationJobnow callsRenewalsService.recordNoticeDelivery()after eachdispatchByTemplateToClient()— records{ channel, dispatchedAt, notificationId, deliveryStatus }per attempt (with'queued'initially,'failed'on exception). Notifications- dispatch delivery webhooks can back-filldeliveryStatustodelivered/bounced/undeliverablelater. Insurance Act §31 "prove notice was sent" is now a single-column read. - gap 12: new
RenewalMetadataDto(offerMessage?,declineReason?,noticeType?,insurerReference?,sanctionsScreeningId?) validated withclass-validator.offer()merges the DTO onto the row; unknown keys land as 400.WithdrawRenewalDtoalso 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.
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-Keyaccepted onPOST /renewals/:id/{offer,decline,accept,withdraw,convert}via thin service-side wrappers using the sharedIdempotencyService(24 h TTL, per-endpoint scope). Same body under the same key replays the memoised response; body-mismatch → 409IDEMPOTENCY_CONFLICT. - gap 9:
@Throttledecorators 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_dayspolicy (default 2555 = 7 y).RetentionPurgeJobextends acrossRenewalQuote— terminal statuses (declined,lapsed,converted,withdrawn) past the window getmetadata,withdrawnReason,noticeDeliveriesscrubbed +retentionScrubbedAtstamped. Emits an aggregaterenewal.retention.scrubbedaudit event. - gap 11:
RenewalsService.findById()now mirrorslist()'s ownership rule. Callers withoutRenewalConvert(compliance-wide) must own the referral's agent or receive 403. Legacy internal callers not passinguser(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/—RenewalQuotelifecycle.pending → offered → accepted | declined | lapsed → converted. Two cron drivers:RenewalGenerationJobwalksPolicy.effectiveToat 60 / 30 / 7 days out and creates the quote + fans out arenewal.notice.customernotification;RenewalSweepJobperforms a duplicate-generation pass + laps offered quotes past theirproposedEffectiveFromboundary.
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]drivesrenewal-generation.job— aPolicywitheffectiveTofalling inside one of the windows gets apendingRenewalQuote- a fan-out via
NotificationDispatchService.dispatchByTemplate(renewal.notice.customer).
- a fan-out via
- Explicit lifecycle transitions:
offer()/decline()write structured audit events per state change. - Convert-to-referral:
convert()mints aReferralindraftlinked back viametadata.renewalOfPolicyId + renewalQuoteId, so the downstream underwriting chain has full provenance. - Idempotent generation:
renewal-generation.jobre-runs are safe — quote creation is upserted on(policyId, proposedEffectiveFrom).renewal-sweep.jobperforms the same generation pass so a missed cron doesn't lose a window. - Agent-scoped list:
list()restricts callers withoutRenewalRead(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.