Reservation Health
A continuous integrity check over reservations. For every non-cancelled reservation — lockouts especially — it asserts the full battery of invariants that should hold given the reservation's type and state: requisite data present, Stripe subscription linked and healthy, billing configured to charge the right amount, payments linked, transfers routed correctly to the location's connected account, access provisioned. Violations are surfaced as alerts before a member or the books feel them.
Why
Today these invariants are checked reactively, by hand, after something has already gone wrong — a member texts that they can't get in, a location's payout looks light, a lockout silently stops billing. The knowledge of what healthy looks like lives scattered across ~15 one-off audit scripts (reservation-audit-*, stripe-audit-sub-transfer*, audit-payments-*, and most completely in reservation-validate-lockout). None of it runs on a schedule. A lockout that drifts off-billing leaks revenue indefinitely until someone happens to look.
This feature makes the invariants executable and continuous: one engine that encodes "healthy," run nightly across the fleet, alerting on any failure. The same engine later answers the per-reservation question ("why is this lockout wrong?") on demand.
What "healthy" means — the invariant contract
Invariants are grouped by dimension and scoped by reservation type/state. A check only runs where it applies (appliesTo).
Data — applies to all non-cancelled reservations
- Has a resource and a user.
- Has
mgId/globalSeq(integrity; overlapsaudit mgid-integrity). - MONTHLY: subscription is linked (
stripeSubscriptionIdset) and not in a terminal cached status. - HOURLY: has a linked Acuity appointment id.
- Not asserted:
startTime/billingStartDatepresence —startTimeis null until a lockout is activated (normal), and a prod dry run showedbillingStartDateis null for ~43% of actively-billing lockouts (migration artifact). Neither is a reliable health invariant.
Subscription — applies to active MONTHLY (lockouts)
stripeSubscriptionIdis set.- The subscription exists in Stripe and its status is
activeortrialing(notcanceled,unpaid,past_duebeyond grace, orincomplete_expired).
Billing — applies to active MONTHLY with a linked subscription
- The subscription's price item matches the effective payment method's expected price (catches the customer-portal drift class: sub-level PM null + customer-level PM changed, no re-price).
billing_cycle_anchoris set and consistent withbillingStartDate.- Post-ADR-008 collection flow is correct (setup_intent vs invoice).
Payment — applies to all reservations with payments
- Every
PAIDpayment has Stripe linkage (charge / payment_intent / invoice id). - No payment rows silently overwritten/poached by an anchor-change trial invoice.
Transfer — applies to active MONTHLY (lockouts) at locations with a connected account
- The subscription's
transfer_data.amount_percentis correct for the price (base price net of fees). - Charges have actually been transferred to the location's connected account (completeness).
Access — applies to all reservations requiring a gate
- An
ACCESS_CODE_CREATEjob exists, an ACTIVEAccessCodeexists, and the UniFi PIN is provisioned. - Already covered by the
reconcile-access-codescron — Reservation Health does not duplicate it; it references it as the access dimension and may surface its findings in the per-reservation view.
Severity
- FAIL — a hard invariant is broken; member impact or money impact is real or imminent. Alerts.
- WARN — drift that isn't yet harmful but will be. Alerts at a lower level.
- INFO — context for the per-reservation view; never alerts.
Constraints
- Read-only. The engine never mutates. Remediation stays in the existing
*-fix-*/migration-fix-*scripts, run by a human against a surfaced finding. (A future closed-loop reconciler, likereconcile-access-codes, is out of scope here.) - Single source of truth. The invariant logic lives in one engine (
ReservationHealthService).reservation-validate-lockout/validate-new-lockoutsand the per-reservation MCP tool become thin callers of it — the checks are not re-implemented per entry point. - No schema changes. All inputs are read from existing tables and the Stripe API.
- Stripe cost is real. Billing and transfer checks make one or more Stripe API calls per lockout; the fleet scan must respect Stripe rate limits and the cron
maxDuration. DB-only checks have no such cost.
Entry points
The same engine, three surfaces (built in this order):
- Cron fleet scan (this feature's core) — nightly, alerts on FAIL/WARN.
- Script —
reservation-validate-lockout <id>reimplemented over the engine. - MCP investigative tool (later, separate plan) — run the battery for one reservation on demand from claude.ai, the natural "drill into the alert" surface.
Relationship to existing crons
reconcile-access-codes— owns the Access dimension (detect and remediate). Not duplicated here.sweep-stuck-stripe-webhooks— owns stuck-webhook alerting (cliff / retry-cap). Reservation Health does not re-check webhooks.sync-stripe-and-refresh-views— syncs Stripe data to the localstripetables + refreshes analytics views Reservation Health may read from.