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.tsto update theInsurancePriceTierlookup table (used by the coverage activity report).
Extending insurance to a new flow:
- Follow
initialize-lockoutroute for howinsurancePriceIdis 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-MMwrites a single landscape PDF with one section per location toapps/api/scripts/.local/insurance-coverage/YYYY-MM/coverage-report-YYYY-MM.pdf. Each section has header + coverage table (frominsurance_coverage_activity) + totals row (frominsurance_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_IDagainst subscription item's price product — not a DB flag. 'none'(string) andnullboth mean "remove insurance" in the lockout update path.immediateuses Stripe prorations;next_cycleusesproration_behavior: 'none'.- Future-dated lockouts are blocked from
immediateadd. - DB fields are snapshots — always check Stripe via
subscription-metadatafor live state. INSURANCE_UPSELLoffer 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