Skip to content

Reservation Health — Implementation Plan

Status: P0–P3 built (engine + 3 nightly crons + both validate scripts folded), tests green, validated against a prod dry run (430 lockouts → checks tuned, see below). [ops] alert rule + deploy pending.

Prod dry run (2026-06-02, read-only, validate-new-lockouts --production)

430 active lockouts. First pass flagged 183 monthly-missing-billing-fields — a false-positive flood. Investigation + tuning: - Removed monthly-missing-billing-fields/-anchorstartTime is null until activation (vault); billingStartDate is null for ~43% of actively-billing lockouts (Cherry City migration artifact). Not a reliable invariant. - Grandfathered price drift → INFO (was WARN) — intentional legacy pricing, not a failure. - Crons skip INFO when alerting (per spec, INFO never alerts).

Final profile: 390/430 healthy, 2 actionable FAILs (lockout-no-payment-method ×1, lockout-transfer-incomplete ×1), 23 past_due WARN (visible, non-paging), 18 grandfathered INFO (silent). Trustworthy alert volume.

Implements spec.md. Builds a reservation-invariant engine and the nightly cron(s) that run it fleet-wide and alert on violations.

Goal

Encode "what a healthy reservation looks like" once, run it nightly across the fleet, and alert (Sentry → watched channel) on any FAIL/WARN. Consolidate the invariant logic currently scattered across the audit scripts — reservation-validate-lockout is the most complete prototype and serves as the de-facto spec for the lockout battery.

Settled decisions

  • Cadence: nightly. No minute/hourly pressure; relieves Stripe-rate and timeout concerns.
  • Granularity: multiple crons, split by cost/dependency. Not one mega-cron — so a Stripe-heavy check can't starve a cheap DB check, and each route gets its own maxDuration budget.
  • Pattern: reconcile-access-codes. Query → Sentry.captureMessage({ level, tags, extra }) per breached invariant → wrap in Sentry.withMonitor (missed-run detection) → runWithAuditContext({ source: 'cron:...' })CRON_SECRET/QStash auth + preview-skip.

Resolved decisions

  1. Source of truth — fold validate-lockout / validate-new-lockouts logic into ReservationHealthService; rewrite those scripts as thin callers. One implementation of each invariant.
  2. Alert routing — cron emits Sentry warning issues tagged feature:reservation-health. Aaron owns a Sentry issue-alert rule on that tag → email aaron@metrognome.com on failures. The [ops] task is his.
  3. Fleet scan — start full every night. Fleet is ~500 total reservations; lockouts (the Stripe-touching subset) are far fewer. Window later only if maxDuration ever bites.

Execution model

[code] = agent-buildable in-repo. [ops] = Sentry/Vercel config (human or me-on-console).

P0 Engine + DB-only cron ──▶ P1 Billing checks ──▶ P2 Transfer checks ──▶ P3 Consolidate scripts
(ReservationHealthService,    (Stripe-touching,      (transfer correctness   (validate-* call the
 data dimension, 1 cron        own cron, 300s)         + completeness, own      engine; MCP tool is
 end-to-end + alert routing)                           cron)                    a later separate plan)

P0 — Engine + DB-only cron (done; proves the whole path)

Goal: one nightly cron running the cheap DB invariants across the fleet, alerting end-to-end, with the engine shape that P1–P3 extend.

[code] ReservationHealthService skeleton ✅ done 2026-06-02

  • apps/api/src/services/health/ReservationHealthService.ts, types.ts, index.ts.
  • HealthCheck = { id, dimension, appliesTo(reservation), run(reservation, deps) → Finding[] }. Finding = { check, dimension, severity: 'FAIL'|'WARN'|'INFO', message, context }.
  • ALL_CHECKS registry; runForReservation(id) and runFleet({ where, checks, deps }) load reservations once (via reservationHealthInclude) and dispatch applicable checks. runFleet returns only reservations with findings.
  • Prisma + Stripe injected via HealthCheckDeps so the engine is testable without live Stripe.

[code] Data-dimension checks (DB-only, no Stripe) ✅ done 2026-06-02

apps/api/src/services/health/checks/data-checks.ts:

  • monthly-missing-billing-fieldsreservation-audit-missing-billing (data half).
  • monthly-unlinked-subscriptionreservation-audit-unlinked-subs.
  • monthly-dead-subscription — terminal cached stripeSubscriptionStatus (webhook-maintained; lags only on a missed webhook, which the P1 Stripe check catches authoritatively).
  • paid-payment-missing-stripe-linkageaudit-payments-no-stripe-linkage (zero-amount comps excluded).
  • hourly-missing-acuityreservation-audit-acuity-drift (WARN).
  • (mgId integrity left to existing audit mgid-integrity, not re-ported.)

[code] Cron route reservation-health-data ✅ done 2026-06-02

  • apps/api/src/app/api/cron/reservation-health-data/route.ts, mirrors reconcile-access-codes (auth, QStash, preview-skip, withMonitor). Read-only — no remediation.
  • Runs the data checks fleet-wide; aggregates findings per check; one captureMessage per breached check, level error (FAIL) / warning (WARN), tags: { feature: 'reservation-health', dimension, check }, extra: { count, mgIds, reservationIds }.
  • apps/api/vercel.json: cron 0 8 * * * + maxDuration: 60.

[code] Tests ✅ done 2026-06-02 (23 passing)

  • Check unit tests (data-checks.test.ts) + engine dispatch tests (ReservationHealthService.test.ts, Prisma/Stripe mocked).
  • Cron route test (tests/api/cron/reservation-health-data.api.test.ts): auth, per-check aggregation, level/tags, clean-fleet → no alerts.

[ops] Alert routing (Aaron — pending)

  • Sentry issue-alert rule: feature:reservation-health → email aaron@metrognome.com on new issues. Not yet created; the cron is inert as an alarm until it exists.

[ops] Deploy (pending)

  • Ships on merge. CRON_SECRET / QStash already configured for sibling crons; no new env. Verify the first nightly run fires (Sentry cron monitor reservation-health-data).

P1 — Billing checks (Stripe-touching) (done 2026-06-02)

One consolidated lockout-billing-health check does a single memoized sub-retrieve and emits findings with distinct codes.

  • lockout-subscription-unreadable — sub can't be retrieved → FAIL.
  • lockout-subscription-status — terminal status FAIL, past_due WARN (Stripe-authoritative; complements the DB cached check).
  • lockout-subscription-metadatapurchaseType/reservationId wrong → FAIL.
  • lockout-no-payment-method — no effective default PM → FAIL.
  • lockout-price-mismatch — sub price vs effective PM ← reservation-audit-pm-gap via CheckoutService.calculateMonthlyCheckoutPrice (paying wrong-PM price → FAIL; grandfathered/legacy → INFO, non-alerting).
  • Deferred: explicit billing_cycle_anchor consistency check and the ADR-008 setup_intent-vs-invoice-flow check (reservation-audit-setup-intent) — not yet ported.

[code] Cron route reservation-health-billing

  • Nightly 0 9 * * *, maxDuration: 300. Sequential per-lockout (one memoized retrieve each). tests/api/cron/reservation-health-billing.api.test.ts.

P2 — Transfer checks (Stripe-heavy) (done 2026-06-02)

  • lockout-transfer-configmetadata.locationId present + matches reservation location + location has a connected account → FAIL otherwise. (Pure; reuses the memoized sub.)
  • lockout-transfer-incomplete — latest paid invoice's charge has a transfer to the connected account. Resolves invoice→charge via the app's invoicePayments path (StripeWebhookService), checks charge.transfer then the transfer_group lookup; conservative skip if the charge can't be resolved.
  • Design change: dropped the transfer_data.amount_percent reverse-math check (stripe-audit-sub-transfers) — transfer_data is the legacy model (validate-lockout flags it INFO-only); modern subs transfer via the charge.updated webhook, so amount_percent would false-alarm. Completeness is the authoritative signal.
  • Deferred: anchor-poached payment detection (audit-lockout-anchor-poached-payment); only the latest paid invoice is checked (recent-payout health, not full history).
  • Engine hardened: a throwing check becomes a <id>-error FAIL finding instead of aborting the fleet scan.

[code] Cron route reservation-health-transfers

  • Nightly 0 10 * * *, maxDuration: 300. tests/api/cron/reservation-health-transfers.api.test.ts.

P3 — Consolidate + investigative surface

[code] Rewrite validate-lockout over the engine ✅ done 2026-06-02

  • Engine exposes evaluateReservationHealth + createSubscriptionLoader so callers own their Stripe/Prisma clients (scripts must — the app singletons bind to env at import, before script-runner loads env). reservation-validate-lockout now fetches with script-runner IO and runs the engine battery; invariant logic single-sourced. Verified against local HOURLY + MONTHLY reservations.
  • reservation-validate-new-lockouts folded — now a thin fleet runner over the engine (keeps the --this-month / --unbilled / all-active scoping + per-reservation reporting; bespoke fee-math replaced by evaluateReservationHealth). This is the prod-dry-run vehicle.

MCP investigative tool — separate plan

  • A read-only check_reservation_health tool on the staff MCP that calls the engine for one reservation — the "drill into the alert" surface. Tracked in mcp-server, not here.

Out of scope

  • Closed-loop auto-remediation (this is detect-and-alert; fixes stay manual scripts).
  • Access dimension (owned by reconcile-access-codes).
  • Webhook health (owned by sweep-stuck-stripe-webhooks).
  • Schema changes (none).