Skip to content

Insurance

Overview

Optional recurring add-on for monthly lockout subscriptions, billed as a second Stripe subscription line item. The system handles billing only — actual insurance policy is external. Pricing lives entirely in Stripe; availability is per-location.

How It Works

Availability: per-location flags (insurance_available, insurance_mandatory). Mandatory flag is frontend-enforced only.

Pricing: Stripe prices under STRIPE_INSURANCE_PRODUCT_ID. No server-side calculation — InsuranceSelector component fetches active prices dynamically. InsurancePriceTier table maps Stripe price IDs to coverage limits/deductibles for reporting.

Adding: at checkout (initialize-lockout with insurancePriceId) or post-checkout (member: POST /reservations/[id]/add-insurance, staff: POST /lockouts/[id]/update). One insurance item per lockout. Member add-insurance returns 400 if insurance already exists (add-only); staff updateLockout can replace an existing item.

Timing: immediate (prorated charge via Stripe prorations) or next_cycle (proration_behavior: 'none'). Future-dated lockouts (billing not yet started) are blocked from immediate.

Removing: staff updateLockout with insurancePriceId: 'none' or null. Both string 'none' and null are treated as remove.

Live state: Reservation.insurancePriceId and insuranceAmountCents are snapshots at attachment time — always check Stripe via the subscription-metadata endpoint for live state.

Identification: Insurance line item is identified by matching STRIPE_INSURANCE_PRODUCT_ID against the subscription item's price product — not a DB flag.

Routes

Route Methods Auth Purpose
/insurance/prices GET public List active Stripe prices under insurance product
/reservations/[id]/preview-insurance POST owner Preview proration cost for adding insurance
/reservations/[id]/add-insurance POST owner Add insurance to lockout subscription
/reservations/[id]/subscription-metadata GET owner/staff Live subscription state including insurance
/lockouts/[id]/update POST staff+ Add/change/remove insurance via insurancePriceId

Code Map

apps/api/src/
  app/api/insurance/prices/                       List active Stripe insurance prices
  app/api/reservations/[id]/add-insurance/        Member add insurance
  app/api/reservations/[id]/preview-insurance/    Preview proration
  app/api/reservations/[id]/subscription-metadata/ Live subscription state
  services/lockouts/LockoutManagementService.ts   Staff add/change/remove (updateLockout)

apps/web/src/
  components/molecules/forms/InsuranceSelector.tsx        Price dropdown (fetches active prices dynamically)
  components/organisms/widgets/AddInsuranceModal.tsx      Member add flow: pick tier, timing, preview, confirm

Data Model

InsurancePriceTier: stripePriceId (String, unique), coverageLimitCents (Int), deductibleCents (Int). Maps Stripe price IDs to coverage limits. Seeded via apps/api/scripts/seed-insurance-price-tiers.ts.

Location: insurance_available (Boolean), insurance_mandatory (Boolean).

Reservation: insurancePriceId (String?), insuranceAmountCents (Int?) — snapshots at attachment time.

MigrationInvitation / MigrationSubmission: insurancePriceId (String?) — pre-selected at migration time.

Patterns

Adding a new insurance tier or changing pricing:

  • Create/update Stripe Price under STRIPE_INSURANCE_PRODUCT_ID — no code changes needed.
  • Re-run apps/api/scripts/seed-insurance-price-tiers.ts to update the InsurancePriceTier lookup table (used by the coverage activity report).

Extending insurance to a new flow:

  • Follow initialize-lockout route for how insurancePriceId is accepted and added as a second subscription item.

Reporting

analytics.insurance_coverage_activity — monthly coverage roster for insurance carrier billing reports. Reservation-based (one row per insured subscriber per month), enriched with exact line item amounts from stripe.invoices.

analytics.insurance_coverage_by_location — per-location monthly rollup of the activity view. One row per location per month with unit count and totals for premium / paid / credit / due. Source of the totals row on the Safe Store filing PDF; also queryable from Metabase for bookkeeping.

Two consumer paths:

  • Monthly carrier filing (Safe Store): apps/api/scripts/export-insurance-coverage-reports.ts --month=YYYY-MM writes a single landscape PDF with one section per location to apps/api/scripts/.local/insurance-coverage/YYYY-MM/coverage-report-YYYY-MM.pdf. Each section has header + coverage table (from insurance_coverage_activity) + totals row (from insurance_coverage_by_location). Staff procedure: ops/insurance/monthly-coverage-report.md.
  • Ad-hoc analysis: CSV export from Metabase against either view.

Related views: analytics.insurance_monthly (aggregate MRR/penetration), analytics.insurance_by_tier (breakdown by tier).

Tests

tests/api/insurance/insurance-prices.api.test.ts
tests/api/checkout/preview-insurance-line-items.api.test.ts
tests/api/reservations/reservation.subscription-metadata.api.test.ts

Gotchas

  • Insurance identified by matching STRIPE_INSURANCE_PRODUCT_ID against subscription item's price product — not a DB flag.
  • 'none' (string) and null both mean "remove insurance" in the lockout update path.
  • immediate uses Stripe prorations; next_cycle uses proration_behavior: 'none'.
  • Future-dated lockouts are blocked from immediate add.
  • DB fields are snapshots — always check Stripe via subscription-metadata for live state.
  • INSURANCE_UPSELL offer shown on account overview when member has a lockout without insurance.

See Also

  • [[lockouts]] — Insurance as a lockout subscription line item
  • [[locations]] — Per-location insurance availability
  • [[migrations]] — Insurance pre-selected during migration