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.
Persistence at initial checkout: initialize-lockout validates the insurancePriceId against Stripe, captures the price's unit_amount, and writes both insurancePriceId and insuranceAmountCents onto the Reservation row at creation. Pre-correction, only the add-insurance upsell and migration paths set these fields — new lockouts with insurance had null values, undercounting the coverage report by roughly half.
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 the carrier filing. Reservation-based (one row per insured subscriber per month). Roster only; member collections live in bookkeeping, not in this view.
analytics.insurance_coverage_by_location — per-location monthly rollup of the activity view. One row per location per month with unit count and premium total. Source of the totals row on the Safe Store filing PDF; also queryable from Metabase.
Two consumer paths:
- Monthly carrier filing (Safe Store): the
export_insurance_coverage_reportstaff-MCP tool is the primary path — it callsGET /api/insurance/coverage-report(viaInsuranceCoverageReportService), renders the PDF in the staff-mcp runtime (pdfkit; never Vercel), saves it under the shared export volume, and returns the file path. Columns are pure roster data: Customer, Unit, Coverage tier, Premium, New, Cancelled — no member contact or financial details beyond the premium snapshot. Drive save is unconditional (the tool callscreate_drive_fileautomatically). Fallback:apps/api/scripts/export-insurance-coverage-reports.ts --month=YYYY-MMwrites the same PDF locally. 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