Skip to main content

Insurer & product catalogue (insurers, products, plans, IRA registry)

Scope

Six modules that together define the counterparty + product surface every referral submission gates against:

  1. insurers/Insurer model: onboarding → active → suspended state machine, IRA licence tracking (registrationNo), contact fields, bordereau cadence.
  2. insurer-products/InsurerProduct: an insurer's specific product filing (e.g. "Sanlam Motor Third-Party 2024"), IRA filing fields (iraFilingReference, iraFilingApprovedAt, iraFilingExpiresAt), commission applicability, active flag.
  3. insurer-subresources/InsurerContact (PII-ciphered via PiiCipher) + InsurerIntegrationSetting (webhook secret, API endpoint config, secret values SecretsCipher-encrypted).
  4. product-categories/ — platform-level taxonomy (motor, life, medical, property, business, marine, agriculture, travel, other).
  5. product-plans/ProductPlan (specific plan variant under an InsurerProduct) + ProductPlanVersion (append-only rate- card snapshot for historical policy recomputation) + per-plan required-document catalogue.
  6. ira-registry/IraLicensedAgent lookup table: cache of IRA-approved licence holders for public verification (throttled public endpoint) + internal enrichment.

Together they gate every POST /referrals at creation and every POST /referrals/:id/submit at PII transmission.

Compliance envelope

  • IRA Insurance Act 2017 §12 — a policy may only be sold on a product with a valid IRA filing under a validly-licenced insurer. Filing + licence must be re-checked at PII-transmission time; a filing that expired between draft + submit is not §12-compliant.
  • IRA Insurance Act 2017 §35 — rate-card filing. Rating factors in force at the moment of issue must be reconstructable from the ledger; ad-hoc rate changes retro-applied to a live book are §35 non-compliant.
  • IRA Insurance Act 2017 §102 — quarterly insurer returns (already covered by the regulator-reports review) — this surface is the upstream ledger that feeds those returns.
  • AML Act 2013 §6 — CDD on the insurer counterparty. Insurers are payment counterparties (premium collections + claim payouts); a sanctioned insurer in the platform is a §6 finding.
  • DPPA 2019 §21 — insurer contact PII (contact person's email / phone). The sub-resource InsurerContact ciphers correctly; the parent Insurer row's contactEmail / contactPhone columns are still plaintext.
  • BOU Cybersecurity Guidelines §5.4 — status transitions chain-audited + cascaded to downstream state.

Current state (2026-07-18)

Module footprint

src/modules/insurers/insurers.module.ts — 259 LOC
src/modules/insurer-products/insurer-products.module.ts — 466 LOC
src/modules/insurer-subresources/insurer-subresources.module.ts — 410 LOC
src/modules/product-categories/product-categories.module.ts — 180 LOC
src/modules/product-plans/product-plans.module.ts — 450 LOC
src/modules/ira-registry/ira-registry.module.ts — 129 LOC

Prisma models: Insurer, InsurerContact, InsurerIntegrationSetting, ProductCategory, InsurerProduct, ProductPlan, ProductPlanVersion, IraLicensedAgent.

What works today

  • Rate-card versioning is shipped: ProductPlanVersion is an append-only snapshot table; resolveAt(planId, at) returns the version in force at a given timestamp so a policy issued 12 months ago still computes against its issue-time rate (IRA §35 compliant).
  • Bordereau cadence is honoured — the BordereauScheduleJob reads Insurer.bordereauCadence and generates the IRA §102 quarterly-return input on schedule.
  • Insurer-contact PII cipher is shipped: InsurerContact.email
    • .phone are PiiCipher.encrypt-ed at rest with HMAC-SHA256 hash siblings for dedup, gated behind the InsurerContactPii permission on read.
  • Integration secrets are SecretsCipher-encrypted at rest; list / get endpoints mask the value.
  • Own-scope enforcement on list() + findById(): insurer- staff callers are restricted to user.insurerId via ObjectAuthorizationService.assertInsurerAccess().
  • Public IRA lookupGET /public/ira-registry/:iraNumber is throttled to 30/min per IP; used by client-portal / onboarding flows to verify an agent's IRA number without an authenticated session.
  • Category taxonomy soft-deleteDELETE /product-categories/:id soft-deletes when the category is still referenced by an active product; hard-deletes when unused.
  • Audit trail — every insurer / product / plan / category / contact / integration state transition emits a structured audit event with before / after snapshots + reason (on suspend).

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release insurer-p1-20260718-0700. Closes gaps 1–4:

  • gap 1 — IraRegistrySyncJob runs weekly (Mon 04:00 UTC) and emits ira_registry.sync audit + flags stale when the newest IraLicensedAgent.importedAt exceeds IRA_REGISTRY_SYNC_STALE_DAYS (default 30).
  • gap 2 — ReferralsService.create() (via ensureAgentAndProduct) and submit() both call assertProductIraFilingValid(); refusals raise INVARIANT_VIOLATION and submit() emits referral.product.ira_filing_invalid audit event.
  • gap 3 — SUBJECT_TYPES gained insurer; InsurersService.create() runs screenForPayout('insurer', …) and on block suspends the insurer + emits insurer.sanctions_block.
  • gap 4 — Migration 20260718090000_insurer_phase1 adds contactEmailHash, contactPhoneHash, piiEncryptedAt to insurers + indexes; InsurersService.create/update cipher + hash-sibling every write, legacy rows round-trip via PiiCipher.decrypt fallback.

Locked in by src/modules/insurers/insurer-phase1.spec.ts (7 cases). Test suite: 478/478 (68 suites).

Phase 2 shipped (2026-07-18)

Release insurer-p2-20260718-0716. Closes gaps 5–8:

  • gap 5 — InsurersService.suspend / deactivate / archive each call cascadeInFlightReferralsOnSuspend() which flips referrals in sent_to_insurer, quotation_pending, quotation_received, quotation_shared, client_accepted, premium_payment_pending to on_hold with metadata.heldReason='insurer_suspended'. Emits aggregate insurer.suspend.in_flight_referrals audit event.
  • gap 6 — new POST /insurers/:id/deactivate + POST /insurers/:id/archive endpoints, gated by new insurer:deactivate + insurer:archive permissions (added to super_admin default). Emit insurer.deactivate / insurer.archive. Deactivating an already archived insurer refuses.
  • gap 7 — InsurerProductsService.create / update / bulkImport funnel iraFiling* through parseAndValidateIraFiling(), which refuses approvedAt in the future, expiresAt in the past, and expires ≤ approved. Bulk-import surfaces filing failures as insurer_product.bulk_import.ira_validation_failed. CSV column set now includes iraFilingReference, iraFilingApprovedAt, iraFilingExpiresAt.
  • gap 8 — ProductPlansService.deactivate queries Referral for the plan with a non-terminal status; on hit, refuses with a sample of affected reference numbers and emits product_plan.deactivate.blocked_by_referrals.

Locked in by src/modules/insurers/insurer-phase2.spec.ts (6 cases). Test suite: 484/484 (69 suites).

Phase 3 shipped (2026-07-18)

Release insurer-p3-20260718-0835. Closes gaps 9–12:

  • gap 9 — InsurersService, InsurerProductsService, ProductPlansService each expose a createIdempotent() wrapper keyed on Idempotency-Key header via the shared IdempotencyService (per-endpoint scope, 24 h TTL). Same body + key replays the memoised response; missing header falls through to the plain create path.
  • gap 10 — @Throttle on every mutation across insurers + insurer-products + product-plans: creates 10/min, updates + activate / deactivate / suspend / archive 20/min, bulk-import 3/min, admin POST /ira-registry/sync 3/min. Excess → 429.
  • gap 11 — new data_sharing.insurer_catalogue_retention_days policy (default 3650). RetentionPurgeJob now scrubs archived insurers past the window: contact PII + contactEmailHash / contactPhoneHash + address are erased, InsurerContact rows scrubbed in-place, and Insurer.piiScrubbedAt is stamped. Emits insurer.retention.pii_scrubbed audit event.
  • gap 12 — new IraRegistrySyncRun ledger table; IraRegistrySyncJob.tick() opens a row at start + closes it with counts + stale flag at end. New endpoints: POST /ira-registry/sync (throttled 3/min, gated by ira_registry:sync permission granted to super_admin) invokes the job manually with initiatedType='admin' + initiatedBy=user.id, and GET /ira-registry/sync-runs lists the ledger.

Migration: 20260718100000_insurer_phase3 — creates ira_registry_sync_runs + adds Insurer.piiScrubbedAt + index.

Locked in by src/modules/insurers/insurer-phase3.spec.ts (4 cases). Test suite: 488/488 (70 suites).

All 12 gaps closed. Insurer & product catalogue module is audit-clean against IRA §12 / §35, AML §6 / §14, and DPPA §11 / §21.


Phased implementation plan

Complete — every phase shipped.