Court booking & club management SaaS for padel, tennis, and pickleball facilities.
“PlayByPoint quality, without the login wall.”
117
Versions Shipped
80+
Features Built
0
Remaining
0
P0 Bugs Open
100%
Progress
Planning
Product Planning
Monthly goals, upcoming iterations, and GA target checklist.
April 2026
Integration Depth & GA Runway
Setpoint PMS
10 goals + 2 iterations + GA checklist
Court booking & club management platform for padel, tennis, and pickleball facilities.
Multi-player court bookings (admin)
Attach multiple players to a court booking via admin; booking_players table with per-player check-in; cost split toggle (locked to organizer-pays-all per DEC-152). Unblocks terminal per-player check-in (handoff to Muhammad).
Admin Check-In Parity (v0.23.1)
Admin's Check-In now produces the same DB end state as terminal's — populated per-player POS tickets ready for checkout. Includes per-player check-in UI (each player row has its own button, booking-level button becomes 'Check In All'). DEC-173.
Check-In Consolidation (v0.23.2)
Resolves DEC-173 tech debt. Admin and terminal now share packages/common/src/check-in.ts as the single source of truth for check-in logic. Both surfaces call the same pure functions with injected auth context. Terminal also picks up three defensive hardening checks that previously existed only on admin (idempotency, already-checked-in precheck, stricter rollback). DEC-174.
Bookings List Cleanup (v0.23.3)
Admin bookings list tightened per boss feedback: Coach + Match columns replaced by a single Type column (Court / Coaching / Event badge); new Created On column becomes the default sort so just-booked rows land on top; Date/Time renamed to Booking Time; Add to Calendar removed from the booking detail dialog; date filter inputs widened to stop native picker overflow. UI-only, no schema change. DEC-175.
Lesson per-student check-in (follow-up to v0.23.1)
Extend the v0.23.1 per-player check-in to group lessons. Requires schema change (add lesson_players.checked_in_at), new admin endpoint (PATCH /api/admin/lessons/[id]/players), dialog rendering of lesson students when booking.lesson exists, and a parallel port in terminal so both surfaces stay in sync. Ticket pricing uses price_per_pax (not the court fee). See BUG-035. Scoped out of v0.23.1 to keep that PR merge-ready.
Separated booking status overload into 3 independent dimensions: lifecycle / payment_status / checked_in. Payment-method selector in create dialog. Stripe payment-link generation for admin-initiated card flows. 12 E2E tests (TC-105).
Unified Booking Calendar (v0.22.0)
Cal.com-inspired month calendar across both apps. Rollup of academy + court + group-lesson joinable slots into a single browse surface. E2E test coverage (TC-010–013) still pending.
Blocker since 2026-04-01. Parallel workstream on fix/stripe-webhook-bug-019 branch. Users pay for memberships but never activate — admin must assign manually.
Centralized Loyalty & Discount Engine (v0.24.0)
Single resolver replaces every ad-hoc per-feature pricing-cascade hook in the system. New crm_customer_groups + crm_customer_group_members + crm_discount_rules + crm_discount_rule_targets + crm_discount_applications tables; resolve_discounts (read-only) + commit_discount_applications (idempotent writer). v0.24.0 ships engine + admin only: admin booking route resolves + commits post-insert and serves as the canonical consumer pattern. calculate_member_price() now delegates to the resolver internally, preserving DEC-183 better-of (subscription vs credit-tier) semantics. Auto-mirror triggers keep crm_discount_rules in sync with membership_plans + credit_tier_configs. POS + Terminal consumer wiring is delegated to the POS/Terminal dev as a follow-up (BUG-082) — clean ownership boundary; the contract (pos_orders.discount_kind/note + pos_order_items.is_foc/foc_reason columns + @setpoint/common/discounts types) ships in this PR for them to write against, mirroring the admin route pattern. Lesson + program consumer wiring deferred (BUG-074); M5 OpenRally surface deferred (BUG-073); legacy POS group tables left untouched per POS dev's call (BUG-075). DEC-186, TC-130, plan-pos-terminal-discount-integration.md.
Adopt React Query (TanStack Query) across admin app
Architectural migration. Surfaced during v0.23.10 (parallel subscriptions + credit tiers) review: 44 admin components currently use the legacy fetch + useState + useEffect pattern, which trips React 19's react-hooks/set-state-in-effect lint rule. The right architectural answer is React Query — replace each useState + useEffect + fetch with useQuery and each mutation handler with useMutation. Win: built-in caching, automatic refetch on focus / mutation invalidation, loading/error states for free, lint silent everywhere. Size: ~1–2 days mechanical conversion + E2E re-run. Scope: apps/admin only initially (apps/web and apps/pos can follow). Until adopted, new components mirror tier-config-tab.tsx / pending-memberships-list.tsx's guarded-async-IIFE pattern as a stopgap.
v1.0 GA — Minimum Bar
3 / 12 complete
All v0.1–v0.11.0 core features stable
Multi-facility support
3+ real facilities in production
Self-serve onboarding
99.9% uptime track record
Embeddable booking widget
Custom subdomain working
Stripe billing operational
Kiosk mode for front desk
GDPR-compliant data export
Privacy Policy + Terms of Service
Custom domain (club.setpoint.com)
Upcoming Iterations
v1.0.0planned
General Availability
Sell to any padel / tennis / pickleball club. All items below must be true before GA.
3+ real facilities in production
Self-serve onboarding
99.9% uptime track record
Embeddable booking widget
Custom subdomain working
Stripe billing operational
Kiosk mode for front desk
GDPR-compliant data export
Privacy Policy + Terms of Service
Custom domain (club.setpoint.com)
v1.1.0planned
Growth & Engagement
Post-GA features to drive retention and club engagement.
Point-of-sale for front desk operations — walk-in bookings, payments, and retail.
No planning items yet.
OpenRally Protocol
3 goals
Open booking protocol for sports facilities — vendor-neutral standard for availability, holds, and bookings.
OpenRally ↔ TPS end-to-end integration
Phases 1–9 of plan-openrally-tps-integration.md complete. Pad Thai Padel live on OpenRally via TPS mobile (facility 625). Backend + protocol + mobile all shipping real bookings end-to-end.
Set-and-forget triggers on real customer behaviour. Multi-step drip sequences. Each step can use a different channel.
Trigger types: first booking, birthday, post-match, inactivity, tier upgrade/downgrade, program enrolment/completion
Delay controls — immediate or N hours/days after trigger
Drip sequences — chained steps, each with own delay and channel
Merge tags for personalised content
Execution log — per-contact log of automation fires
Pause / resume without deleting
crm-v2.0planned
Advanced Analytics & Network
RFM scoring, sport activity score, churn risk engine, playing partner network, referrals, and family/group accounts — only possible with Setpoint's full data stack.
CRM "Last Visit" includes POS orders + tickets (E1)
v0.23.13
"Paid with Package" badge for package-paid lessons (C2)
v0.23.12
Discount Rules — Multiple Targets per Rule (E3)
v0.23.11
Per-Court Coach Reservation (A5)
v0.23.7
Atomic Credit Booking — BUG-021
v0.23.6
Known-Issues Reconciliation — BUG-018
v0.23.5
Email Lookup Case Normalisation
v0.23.4
Lesson Per-Student Check-In
v0.23.4
Admin Dark-Mode Fix
v0.23.17
Week-view all-courts per-court swim lanes
v0.23.3
Bookings List Cleanup
v0.23.2
Check-In Consolidation
v0.23.1
Admin Check-In Parity
v0.23.0
OpenRally Payment Sync
v1.0.0
General Availability
v1.1.0
Growth & Engagement
v0.53.22Shipped
Academy → Billing: the Collected tile reads the POS and agrees with Revenue to the satang
14 Oct
Two Academy screens disagreed about 'collected' for the same period: Billing's Collected tile summed the academy's own records (academy_payment_summary — package payments, drop-in seats, requests) and read ฿282,000 for PTP September, while Academy → Revenue (v0.53.21, academy_revenue_summary) reproduced the investor definition from pos_order_items and read ฿678,713. The 2026-10-13 forensics showed the gap is structural — staff ring academy items up at the till as ordinary POS sales (฿317,837 since 18 Sep), those sales never write `payments`, and the academy→POS bridge is one-way — so a club owner reading Billing's tile as the academy's takings was misled by roughly 7×. Nestor: "accept POS as the academy truth". Billing's Collected headline now reads academy_revenue_summary() for the same calendar days (p_include NULL — the investor default, never a widened selection), called by the summary route in parallel with academy_payment_summary; no new function and no migration, the POS function being already applied on production. The records number is demoted, not deleted: the line under the number names the mirrored share, and the house (i) beside the label — Members' MetricHelp lifted into components/admin/metric-help.tsx — spells out the definition (through the POS, item level, gross of whole-order discounts, the investor definition, by the till's business day in the facility's timezone) and quotes what the academy's own records show for the period and how much of that reached the till. Because the (i) is a button, the tile card is now a div whose number and detail line are the click target (a button inside a button is invalid HTML); testids are unchanged. The Due side is untouched — Outstanding, Overdue, Renewals, the feed, BILLING_PRODUCT_FILTERS and the drop_in guard still read the academy tables. Period semantics are stated on screen: Collected follows the period by the till's BUSINESS DAY, the list and the records figure by when each record was created, and the list is a different population that does not add up to the tile — Revenue itemises it, which is where the tile's click now goes instead of filtering the list to Paid. A failed POS read renders '—' with the reason and never falls back to the records figure (lib/academy-billing-collected.ts, under unit test): a silent 7× understatement under the same label is the defect being removed. Revenue's 'How this relates to Billing' paragraph, which said 'Billing's Collected tile reads ฿X' about the records figure, is reworded so it stays true. Reconciled read-only on production 2026-10-14 04:39 Bangkok: September 67,871,291 satang = ฿678,712.91 → ฿678,713 (mirrored ฿57,500; records ฿282,000); 1–14 October 33,512,033 = ฿335,120.33 (mirrored ฿98,875; records ฿128,375) — the brief's ฿307,420 was measured on 13 Oct before that day's six academy till lines (฿27,700, 12:36–21:43), and 307,420.33 + 27,700 = 335,120.33 exactly; 1–13 Oct returns the same figure because no 14-Oct sale had closed. Read-only; nothing in the money recording path changes (DEC-547).
apps/admin/src/app/api/admin/academy-payments/summary/route.ts — calls academy_payment_summary (instants in the facility timezone) and academy_revenue_summary (calendar days, p_include NULL) in parallel; each failure nulls only its own figures and is logged. Response: collected_pos_amount (headline), collected_mirrored_amount, collected_records_amount, collected_unavailable_reason; `collected_amount` is gone so no reader can keep the old meaning by accident.
apps/admin/src/lib/academy-billing-collected.ts (+ .test.ts, 10 tests) — the pure half: both payloads reduced to three nullable numbers (a missing or malformed value is null, never 0), the headline (POS only — never the records figure), the detail line with the mirrored share, and the (i) definition text quoting the records figure when known.
apps/admin/src/components/admin/metric-help.tsx — the house (i) (hover tooltip + click/tap popover, one button) lifted verbatim from Academy → Members, which keeps its private copy tonight because a concurrent change is editing that file's tooltips; pointing Members here is a one-line follow-up.
apps/admin/src/components/admin/academy-payments-client.tsx — Collected reads the three figures through the lib; Tile gains `help` and the card/click-target split (number + detail are the button, the label row sits outside it), and the value gets Members' `truncate` + title — measured: without it a text-2xl money figure poked 9 px past the tiles grid at 390 in the w-48 shell; the Collected click goes to /academy/revenue; the scope note states which clock each figure follows and that the list does not add up to the tile. Outstanding / Overdue / Renewals and the feed untouched.
apps/admin/src/components/admin/academy-revenue-client.tsx + lib/academy-revenue.ts — 'How this relates to Billing' now says Billing's tile reads this same figure and that the quoted ฿X is the academy's own records (Billing's list); the till tile's detail reads 'Not in Billing's list'. Testids kept, so the v0.53.21 demo still passes.
apps/admin/e2e/demos/academy-billing-collected.e2e.ts — recorded Playwright demo on the Revenue demo's seed (DEMO-AR): a direct SQL sum and the RPC agree (1,787,500 satang), Billing's tile reads 17,875.00 with '4,500.00 of it mirrored from Academy records', the (i) opens and quotes the records figure 14,450.00, the tile click lands on Revenue reading 17,875.00 with the relation paragraph quoting 14,450.00; page overflow 0 px at 1280 / 768 / 390 and tiles-grid overflow 0 at all three (the untouched nine-column feed table's own internal scroller is logged, not asserted — a pre-existing debt against the no-sideways-scroll rule, listed for follow-up).
apps/staff-docs academy/billing page — the Collected sentence; apps/admin/.../academy-payments/_lib/feed.ts header — which function feeds which tile.
Still reading `payments` for an academy 'collected'/'revenue' figure, listed for a follow-up and NOT changed here: Academy → Members' Lifetime value (academy_member_facts → member-metrics.ts; per-member, POS lines carry no student, so not a one-line swap); Revenue's own relation line and the Dashboard's scope note (records-based by design, they explain the difference); Collections' per-customer payment history (a receivables view).
Verified: unit suite 442/442 (baseline 432/0 + 10), tsc exit 0 (apps/admin and apps/staff-docs), eslint 0 findings on every touched file, apps/docs build exit 0, the demo recorded on a worktree dev server (:3000) against local Supabase.
0
v0.53.21Shipped
Academy → Revenue: the investor figure for academy money collected through the POS, with its composition visible
13 Oct
Nestor, 2026-10-13: "academy pos total that we are reporting to investors … we need to be accurate" and "let's also improve billing adding the events". Academy → Billing lists the academy's OWN records and the mirror they cast into the POS (22 orders, ฿156,375 at pad-thai-padel); the till sells academy products as ordinary POS items far more often than that — 'Academy Programs' alone carries ฿1,077,170 across 473 lines — so the figure reported to investors was built by hand from a direct SQL query over pos_order_items. A new Revenue screen under Academy (directly below Billing) reproduces that query in ONE SQL function, academy_revenue_summary(), and shows what the figure is made of: every category sold in the period with its amount, included or not; coaching rung up without a category (the investor definition's name-match heuristic) as its own line; the uncategorised remainder as its own line; and a footer that proves included + not included + no category = every POS line. The investor definition — item level, gross of order-level discounts, completed non-voided orders by the till's business date, leaf category or uncategorised item name containing academy / coach / lesson / package — is the default and is named as such under the number; ticking a category ("plus events") widens the figure and the sentence then says it is NOT the investor definition. Anything registered in the POS is paid, so there is no Due/Paid switch. Academy records that never reached the till — payment-link ('pending') and credit package sales the mirror skips by rule, and drop-in seats marked paid on rosters — are listed beside the figure, never inside it, together with Billing's own Collected for the same window so the two screens' relationship is stated. Reproduced on production read-only before the function was written: pad-thai-padel September 2026 ฿678,713 and 1–14 October ฿307,420, pad-thai-padel-bali September Rp 326M — the exact figures on the investor pages (DEC-545). Read-only; nothing in the money recording path changes (DEC-546 records the pos_orders.source attribution gap found on the way, and defers its fix to Nestor).
supabase/migrations/20261012231839_academy_revenue_summary.sql — academy_revenue_summary(p_facility_id, p_from date, p_to date, p_include uuid[] DEFAULT NULL) RETURNS jsonb, LANGUAGE sql STABLE SECURITY DEFINER, service_role only. One statement: the rule (match_terms, returned), per-category totals with mirrored share, the two uncategorised buckets with their top item names, the included figure with till/mirrored split, order-level discounts on the carrying orders (unallocated) and mixed-order count, voided/open orders excluded in the window, every-line total, Billing's collected_amount for the same instants (calls academy_payment_summary rather than re-implementing it), academy payments with no mirrored order by method (source_id is prefixed 'academy_payment:<id>'), and roster drop-ins.
apps/admin/src/app/api/admin/academy-revenue/route.ts — GET with facility_slug, date_from/date_to (facility calendar days), optional include (ids | none); requireFacilityAccess; 503 with the reason on an RPC failure, never zeros.
apps/admin/src/lib/academy-revenue.ts (+ .test.ts, 14 tests) — the pure half: ?include= parsing against known ids, the URL value (absent when the selection is the default), the definition sentence, and reconcile() — four identities re-checked in the browser and rendered in red when a payload does not add up.
apps/admin/src/components/admin/academy-revenue-client.tsx — the house date range picker (past-only, month to date), four tiles (Academy revenue · Rung up at the till · Mirrored from Billing · Paid in Academy, not in POS), the definition paragraph, the composition table (checkbox · category · lines · orders · collected; counts drop below md / lg so the table fits — no horizontal scroll) with footer reconciliation, and the 'recorded elsewhere' section with the Billing relation.
apps/admin/src/app/[facility]/academy/revenue/page.tsx + admin-sidebar.tsx — Academy → Revenue nav entry directly below Billing (TrendingUp).
apps/admin/e2e/demos/academy-revenue.e2e.ts (+ .seed.sql / .cleanup.sql) — recorded Playwright demo on local seeded data: the figure loads, the period changes the window, a category toggle widens the figure and relabels the definition, and the footer reconciles; measures no horizontal page scroll at 1280 / 768 / 390 (the 390 shell overflow is the known w-48 sidebar).
0
v0.53.20Shipped
Sidebar handle on the divider; detail views and long forms become right-sliding panels (audit of all 57 Dialog users)
13 Oct
Two things Nestor asked for on production, 2026-10-13. (1) "the open and close bar should go down and be right in the level of the line that separates logo and club picker and the rest of menu" — the round ‹ › collapse handle moves from the facility row's centre line (top-4) to the 1px border under it (top-11), where it straddles the divider in both the expanded w-48 sidebar and the collapsed w-16 rail; it gains an explicit focus-visible ring and keeps its state-aware aria-label. (2) "do an audit and move all modals to be a sliding one from right except for simple dialogs … it should be like booking details" — booking details was itself a centred Dialog (max-w-lg, max-h-[90vh]); nothing in apps/admin rendered a right panel except the four Sheet panels (Members, Collections, Customer, Payment reminder), so those are the template and booking details is the first thing converted to it. All 56 component files using <Dialog> were classified against one rule (DEC-544): a panel when the operator works INSIDE it (any detail/record view; any form that already needed its own scrollbar or sticky footer, or has sections/steps/tabs), a centred dialog when the operator ANSWERS it (confirm/destructive prompts, pickers, result/alert displays, one-block quick-entry forms that fit without scrolling) or when the content is intrinsically wider than the sm:max-w-xl panel (weekly schedule grids, email previews, the roster table). Result: 17 classified as panels, 39 stay dialogs; 10 of the 17 converted and browser-verified tonight, 7 left as Dialogs and listed for a follow-up (3 of them inside PARKED features). No behaviour changed inside any converted surface — same state, validation, submit handlers, toasts, Escape, focus trap, cancel-reason and no-show confirmation steps, sticky action footers; only the container (Dialog → Sheet) and the tagline, which Sheet renders visibly like the house panels do. Nothing touches the money recording path (CLAUDE.md §5): no write, label or timing changes anywhere.
admin-sidebar.tsx — handle at `absolute -right-3 top-11` (facility row h-14 + its wrapper's 1px border-b → divider centre at 56.5px; 24px handle centred at 56px) with focus-visible:ring-2; both rail widths measured within 1px of the divider; club picker and first nav item hit-tested with elementFromPoint and real clicks.
Converted to the house right Sheet (`flex w-full flex-col gap-4 overflow-y-auto sm:max-w-xl`, full-width on phones): booking-detail-dialog (the named reference), create-booking-dialog, edit-booking-dialog, lesson-form-dialog, class-form-dialog, coach-form-dialog, package-form-dialog, academy-settings-dialog, assign-package-dialog (keeps its data-testid and SheetFooter), pricing-rule-dialog. Files keep their historical *-dialog names and exports so the 3 calendar/list mount points are untouched.
Classified as panels but NOT converted tonight (still Dialogs): discount-rule-editor (max-w-2xl, local DB has no discount table to verify against), court-images-dialog (media grid), group-report-settings and report-subscriptions-settings (their edit forms only — delete/preview stay dialogs), and the PARKED program-form-dialog, match-form-dialog, match-detail-dialog.
e2e/tc-302-sidebar-toggle-on-divider.e2e.ts (3 tests: expanded geometry + hit tests, collapsed geometry + hit tests, keyboard reach + focus ring) and e2e/tc-303-right-sliding-panels.e2e.ts (5 tests: create → detail → edit booking flow incl. a real booking submit; schedule lesson; class/coach/package incl. a real package submit; academy settings / assign package / pricing rule tabs; 768 and 390 widths). Written RED first against main (toggle 28.5px above the divider; dialogs 128–416px off the right edge), GREEN after.
e2e/demos/sidebar-divider-toggle-and-right-panels.e2e.ts — recorded Playwright demo (no narration) of the handle in both states and every converted panel opening, being used and closing.
Verified with the repo's Playwright Chromium through the real OTP login on a worktree dev server (:3031) against local Supabase seed: tc-302 3/3, tc-303 5/5; page scrollWidth == clientWidth with a panel open at 1280 (1280/1280) and 768 (768/768); at 390 the panel is full-width with no internal sideways scroll and opening it does not widen the page beyond the known w-48 shell overflow. Unit suite 418/418, tsc exit 0, eslint: 0 new findings on touched files (19 pre-existing errors in those files sit outside every hunk; left alone to keep the diff to the container swap). apps/docs build exit 0.
8 E2E tests
v0.53.19Shipped
Open-price catalog items + manager-gated price override on a bill line (POS-07/POS-08)
8 Oct
Pablo's Bali operations punch list. POS-07: a catalog item can now be marked "Open price" — the cashier is prompted for a price at the moment it's sold instead of a stored catalog price, staff-allowed (no manager PIN) since it's a normal sale mechanic, not a discount. POS-08: a single bill line's price can be overridden (not the catalog price), gated behind the same manager-PIN approval SEC-01's manual-discount flow already uses, and logged to the activity log for audit (action: price_override_applied). See DEC-543 for the full write-up, including why both features are scoped to apps/terminal only for the at-sale/override UI (apps/pos's own checkout route has no PIN gate to reuse and is a separate, already-flagged dead surface — DEC-501) while the price_mode toggle itself ships in both apps' item modal (catalog management happens from either).
supabase/migrations/20261008060715_pos_catalog_items_price_mode.sql — additive: pos_catalog_items.price_mode text NOT NULL DEFAULT 'fixed' CHECK IN ('fixed','open'). pos_item_variations.price_cents is untouched; for an open item it's read only as an optional suggested starting value.
apps/terminal + apps/pos item-modal.tsx / item-variations-section.tsx — "Open price — ask for price at sale" toggle next to the existing Variations toggle; price input becomes optional ("Suggested price") and the required-price validation moves into itemModalSchema's superRefine, gated on price_mode.
apps/terminal menu-grid.tsx tap handler (via terminal/page.tsx's handleItemClick) + keypad.tsx — an open-price item bypasses the variant/modifier dialogs and reuses the Custom Amount keypad UI (now parameterized: requireCategory, title, confirmLabel, initialAmountCents) to prompt for a price, prefilled with the variation's stored price as a suggestion.
apps/terminal terminal-store.ts — addToCart takes overridePriceCents/isOpenPrice (open-price lines never merge with each other); setLinePriceOverride/removeLinePriceOverride for POS-08, keeping the pre-override price for the cart line's strikethrough/"overridden" badge display.
apps/terminal cart-item-modal.tsx — "Price override" control as a sibling to the existing manual-discount control, sharing that section's manualAllowed/DiscountApprovalGate unlock rather than a second PIN prompt.
apps/terminal use-order-mutations.ts — on a genuine (non-replay) checkout commit, writes one pos_activity_logs row per overridden line (old price, new price, reason, actingUserId) — no pos_order_items column, no checkout RPC change.
apps/terminal + apps/pos order-detail-modal.tsx (all three variants) — "Overridden" badge + strikethrough original price, matched to the activity log by (item_variation_id, unit_price_cents). apps/pos/src/queries/use-activity-logs.ts (new) + apps/terminal's existing use-activity-logs.ts both export usePriceOverrideLogs.
Discount percent and payment breakdown on the bill detail, receipt, and emailed bill (TRX-04/TRX-05)
8 Oct
Pablo's Bali operations punch list: a closed bill showed a discount's amount but never its percent, and an emailed bill never said how it was paid. Root cause for the percent was deeper than display — checkout-dialog.tsx's pendingDiscount.type/input never made it past use-order-mutations.ts, which hardcoded every discount row as type:'fixed' regardless of how the cashier actually entered it (same bug, independently, in all three checkout paths: apps/terminal, apps/pos, and the payment-link cron). One migration adds a nullable pos_order_discounts.percent column, written at checkout time, plus a discounts/payments/change_given_cents extension to get_bill_payload_for_order — the one function already feeding both the printed receipt and the emailed bill, so neither surface can drift from the other. See DEC-542.
supabase/migrations/20261008055502 — pos_order_discounts.percent (nullable, additive); create_pos_order_atomic writes it only for a type='percentage' row with a sane [0,100] value; get_bill_payload_for_order gains discounts[]/payments[]/change_given_cents (change derived from pos_drawer_transactions' 'pay_out' rows, not invented).
apps/terminal + apps/pos: use-order-mutations.ts (till-checkout) and check-payment-links/route.ts (payment-link cron) now forward the real discount type/percent instead of hardcoding 'fixed' — fixes the bug on all three checkout paths, not just the one named in the ticket.
packages/common/src/pos-orders.ts — new discountPercentLabel(): a real stored percent renders bare ('10%'); an unknown one (pre-migration row, or a non-percentage type) derives amount_cents/basis and flags it with '≈', same convention DEC-497/REP-03 established for the Discounts report one day earlier. Wired into order-detail-modal.tsx in both apps (all 3 modal surfaces).
print-relay/src/escpos.ts — printed bill now lists each discount with its percent (exact or ≈) and adds a PAYMENT section (method, card last4, tip, Change Due) — both purely additive on the existing payload, so an in-flight job queued before this migration still renders exactly as before (14 unit tests, all backward-compat cases covered).
supabase/functions/send-bill-email/index.ts — new Payment block (method label hand-ported, same reasoning as print-relay: Deno edge functions can't import @setpoint/common), listing every tender with its tip, a Total Paid line for a split payment, and Change Due for cash. Renders nothing when the bill has no payments key (pre-migration payload) — exact parity with today for an old job.
1 E2E tests
v0.53.17Shipped
Academy → Members — the package-deduction ledger as a second table, and in every member's detail
13 Oct
Nestor, on production: "in members we should be able to show package deductions table switching the table from members, find a way those deductions should be added in the detail of the player today" — both, not one. The ledger already existed (academy-sessions-ledger.tsx, a Billing section since DEC-492), so this is wiring, not construction: the Members screen gains a Members · Package deductions segment over its table region — the same pill Billing and the list's product filter draw — persisted as ?view=deductions the way Billing persists its own segment; the one ledger renders under the screen's period with no second picker; and the member detail panel gains a Package deductions section fed by the per-student ledger route. The tiles stay in both views and a note in the deductions view states what the two tables mean by the period (overlap vs the lesson's date) and by a session (a confirmed seat vs a package credit written at booking). The ledger trades its scroll sentinel for the members list's Previous · Page X of N · Next, both ledger routes gain an id tie-break, and nine columns that measured 1,087px against a 1,038px shell fold to seven with nothing lost (DEC-523).
academy-members-table.tsx (new) — owns the Members ↔ Package deductions switch: house role=tablist segment, ?view= read once on arrival and written with history.replaceState (default not written), a deductions-only scope note under the switch, then either PlayerSubscriptionsList (keyed by the assign refresh counter, as before) or AcademySessionsLedger with the screen's period. The Members client changes by one import and one JSX hunk.
lib/members-view.ts — parseMembersView (unknown → default, including Billing's own due/paid/all arriving through a forwarded link) and withMembersView (set when away from the default, delete when on it, every other key untouched); 7 unit tests.
academy-sessions-ledger.tsx — optional `period` prop (host-owned window, no own picker, period excluded from 'Clear filters'); Previous · Page X of N · Next with pageWindow, bounded by the server's window rather than total so the pager can never walk into empty pages; filter changes return to page 1; an emptied page clamps to the last real one; count reads 'Showing 1–25 of 48 deductions'. Seven columns: Date & time, Session (type under the class name, lg+), Coach (xl+), Player, Package (xl+), Deducted ('−1' over 'N left'), Status.
GET /api/admin/students/[userId]/package-transactions — `scope=deductions` applies the facility ledger's LEDGER_SCOPE_OR in the database before count and range (unknown scope → 400, like an unknown type); lessons embed carries status → `lesson_status`; `.order('id', desc)` tie-break under created_at. GET /api/admin/package-transactions gains the same tie-break before its window. parseLedgerScope + 4 unit tests.
academy-member-detail-panel.tsx — 'Package deductions' between Enrolment history and Recorded payments: a divide-y list (no <Table>, the panel's standing rule), 50 newest with 'Showing the N most recent of M' when clipped, its own skeleton / 'No package deductions on record.' / inline error states, and per row the class (or reason label), ±N with the balance left, the lesson's date and time, the package, when it was written, 'Lesson still ahead' when the lesson has not started, 'Lesson cancelled', 'Reconstructed' for backfill. describeDeduction in academy-members-types.ts, 7 unit tests; the reason→label map moves to lib/package-ledger-reasons.ts and the student page's activity table reads it from there.
Verified with the repo's Playwright Chromium through the real OTP login on a worktree dev server (:3400) against a 57-row local fixture: switch both ways, ?view=deductions survives a reload and is dropped on the default, Page 1 of 2 → 2 → Next disabled → back to the same first row, a type change returns to page 1, tiles visible in both views, no second picker; panel for a member with 21 deductions (1 still ahead, 1 returned, 0 tables) and 'No package deductions on record.' for one without. No page or table scrolls sideways at 1280 (1038/1038 both views) or 768 (526/526 both views); at 390 the known w-48 sidebar leaves 148px of content and both tables overflow their wrapper as the members list already did. Billing's section unchanged with its own picker. Unit suite 406/406, tsc 0, eslint clean on touched files.
0
v0.53.16Shipped
Academy → Members — every definition sits with its number; Renewal rate gets its own 3 / 6 / 12 months / all-time window
13 Oct
Nestor, on production, 2026-10-13: "remove this from members add tool tips or explanations below the number" — the nine-line paragraph under the eight tiles — and "also renewal rate should be able to switch from 3,6,12 months" … "or all time". The paragraph is gone and nothing in it was dropped: each tile's detail line now LEADS with the window it answers for (Today · the period's dates · All time), keeps a few words under the number, and carries the precise definition behind an (i) beside its label — the house Radix Tooltip for hover and keyboard focus, and the house Popover on the same button for click, tap and Enter, because Radix's tooltip ignores touch and club staff work on tablets. The sentence about the LIST (one row per enrolment, so a member with two packages appears twice; the tiles count each member once) moved beside the list's count. Renewal rate no longer follows the period: it has its own trailing window ending today — 3m · 6m · 12m · All — switched on the tile, carried in the URL as ?renewal= the way Billing carries ?view=, and computed for all four in one summary so the switch is instant. No database work: academy_member_facts already returns every member's full enrolment history (the enr CTE has no date predicate; p_from/p_to scope only sessions_in_window). The definition is unchanged — renewed ÷ decided, with endings still inside the 30-day grace left out of the denominator and counted on screen — and the fraction is always shown, so 2 of 2 never reads as a trend. Verified against production read-only with the real code: September endings reproduce exactly (7 of 11, 27 in grace = 64%); all four trailing windows at pad-thai-padel currently read 16 of 29 decided (55%) because the club's earliest ending is July 2026 — there is not yet three months of history for a wider window to reach (DEC-522).
member-metrics.ts — RENEWAL_WINDOWS ['3m','6m','12m','all'], renewalWindow(key, today) (calendar months back via addMonths, clamped to the shorter month's last day; 'all' open at the start), summarizeRenewal(facts, window, today) extracted from summarizeMembers; MembersSummary.renewal is now one RenewalSummary per key, each carrying its from/to. 7 new unit tests: month clamping, the four windows, the key guard, grace exclusion at every window, inclusive first-day boundary, zero denominator, and the summary ignoring the screen's period.
academy-members-types.ts — TileCopy gains `scope`; lifetimeValueTile leads with 'All time'; renewalTile(summary, graceDays) renders '64%' + '7 of 11 decided · 27 still in 30-day grace', or '—' with the reason ('No enrolment ended in this window' / '3 ended, all still in 30-day grace'). 6 unit tests.
academy-members-client.tsx — the scope paragraph removed; Tile draws scope · detail under the number and an (i) (MetricHelp: Tooltip + Popover on one focusable button); the Renewal tile carries a compact segmented switch (same control as the product filter) and persists ?renewal= with history.replaceState, restoring it on load and falling back to 3m on an unknown value; useSearchParams wrapped in Suspense as on Billing.
player-subscriptions-list.tsx — 'One row per enrolment — a member holding two packages appears twice; the tiles above count each member once' sits under the Showing X–Y of N count.
Verified with the repo's Playwright Chromium against the local stack through the real OTP login: paragraph gone, every tile's scope and definition present, tooltip on hover and on keyboard focus, popover on Enter and on a tablet tap, the switch changes the number, the fraction, the window dates and the URL across all four windows, deep link restores the window; no horizontal page scroll at 1280 / 768; at 390 the tiles grid fits and the only overflow is the documented w-48 sidebar. Unit suite 400/400, tsc 0, eslint clean.
0
v0.53.15Shipped
Academy → Members — lifetime value leads with the average; every active package holder is reachable
13 Oct
Nestor, on production the day v0.53.14 shipped: "lifetime value should be average per user not total" and "this members not showing all the packages we have in billing, it should show anyone with active package of any type". The first is a presentation swap: the tile now headlines the average per member and its detail line states the denominator in full (total ÷ members ever enrolled) — ฿2,342,250 ÷ 122 = ฿19,198.77 at pad-thai-padel, verified read-only. The second was investigated before anything was changed: every active package holder WAS in the filtered set (79 members on 89 active rows, session_bank 68 / recurring 20 / credit_pack 1, none excluded by the month-to-date overlap, the product join or any default). What hid them was the list itself — the route pages at 50 with no ORDER BY, and the list had no pager, so the default view showed an arbitrary 50 of 181 rows and the Active chip an arbitrary 50 of 89. Fixed with a deterministic order (newest enrolment first) and the same Previous · Page X of N · Next the Billing screen draws (DEC-520). No table scrolls sideways; measured at 1280 / 768 / 390.
academy-members-types.ts — lifetimeValueTile(summary, currency): label 'Lifetime value per member', headline = average_lifetime_value, detail = '<total> total ÷ <n> members ever enrolled · all time · <CUR> only'; '—' when the average is null, null (Tile's own unavailable state) when the summary is. 5 unit tests; member-metrics.test.ts gains one pinning the denominator as members_total (churned and gifted included), not active members.
GET /api/admin/player-subscriptions — `.order('starts_at', desc).order('id')` before `.range()`, so a page is the same page twice and the Active view's 89 rows paginate deterministically.
player-subscriptions-list.tsx — sends page and limit explicitly (PAGE_SIZE 50); count reads 'Showing 1–50 of 181 enrollments'; Previous · Page X of N · Next appear when there is a second page; any filter, search or period change returns to page 1; a page that stopped existing (filter or cancellation shrank the set) lands on the last real page. The client-side second pass over the search, which could drop rows the server returned ('john smith' matched every John and Smith server-side, then only a literal 'john smith' client-side), is removed — the server is the one authority.
lib/page-window.ts — pageWindow({ total, page, limit }) → { pages, from, to }, the one place the boundaries are computed; 6 unit tests (empty set is 0 of 0, exactly one page's worth makes no empty second page, a page past the end clamps).
Scope note states the row unit: one row per enrolment, so a member holding two packages appears twice; the tiles count each member once — the 79 vs 89 gap, said on screen.
Verified with real Chrome through the OTP flow on a local dev server against a 59-active-row fixture (48 session_bank, 9 recurring, 2 credit_pack): tile reads as the average with its denominator, page 2 reachable and distinct, a holder of each package type present and badged; no horizontal scroll at 1280 / 768 / 390. Unit suite 388/388.
0
v0.53.14Shipped
Academy → Members becomes a CRM
13 Oct
Nestor: "members needs to also be more CRM like, needs to include further information of the overall performance, churned, new members, ltv, etc" and "let's also have a detail view in members as CRM one". The roster gains member-level figures on every row (tenure, lifetime value, sessions this period against the member's own monthly average, last seen, an at-risk verdict), eight facility tiles above it (active, new — first-ever enrolment only, churned, renewal rate with a real denominator, average tenure, sessions per member, at risk, lifetime value in the club's own currency), and a right-side detail panel per member. Every metric states its window and its definition on screen. The heavy counting is one new RPC, academy_member_facts (migration 20261012191452, NOT applied to production), paged to completeness on a unique key with a refusal ceiling; every rule that turns facts into a verdict is a named constant in member-metrics.ts with a unit test. At risk is relative to each member's own rhythm (DEC-508): the naive fixed-window rule flagged 91% of credit-holding enrolments on production, this one flags 25 of 79 active members at pad-thai-padel. No table scrolls sideways (DEC-513).
Migration 20261012191452 — academy_member_facts(p_facility_id, p_from, p_to, p_user_ids) RETURNS TABLE: one row per member with enrolments (each carrying a derived ended_at: expiry, last credit spent, or cancellation), recorded package payments, confirmed past seats all-time and in-window, distinct training days, the last 8 day-gaps, and seats per month for 12 months. SECURITY DEFINER, service_role only, ordered by user_id for paging. Written, not applied.
player-subscriptions/_lib/member-metrics.ts — the definitions: CHURN_GRACE_DAYS = 30, AT_RISK_MIN_TRAINING_DAYS = 4, AT_RISK_GAP_MULTIPLIER = 3, AT_RISK_FLOOR_DAYS = 14; tenureDays, lifetimeValue (recorded payments per enrolment when present, else paid non-complimentary enrolments at purchase price — DEC-510), lifecycleOf (active / lapsed / churned), assessAtRisk, averageSessionsPerMonth, summarizeMembers (new = first-ever, churned in window, renewal rate over DECIDED enrolments, sessions per member over members enrolled in the window, LTV total per facility currency). 30 unit tests pin every rule.
player-subscriptions/_lib/member-facts.ts — one loader for the list, the tiles and the panel: pages the RPC to completeness (1,000 per page on the user_id key) and refuses past 5,000 rather than returning a partial club as the whole.
GET /api/admin/player-subscriptions — every row gains `member` (tenure, LTV, sessions vs own average, last seen, at-risk) for the page's members; a failed facts read fails the request rather than blanking the columns.
GET /api/admin/player-subscriptions/members/summary — the eight tiles plus the definitions they quote; GET /api/admin/player-subscriptions/members/[userId] — profile (email, phone), facts and metrics for one member, 404 outside the facility. Both READ_ROLES via requireSubscriptionFacility.
academy-members-client.tsx — the period (the one shared DateRangePicker) now lives at screen level so tiles and rows read ONE window; eight tiles in Billing's tile shape; a scope note stating which tiles follow the period, the churn/renewal/at-risk definitions and why check-ins are not the signal.
player-subscriptions-list.tsx — twelve would-be columns folded into six (Player + tenure, Package + sessions-left/expiry, Status + payment, Activity, Value, actions) so the table fits at 1280 and 768 without a sideways scroll; row click and a 'View member' menu item open the panel.
academy-member-detail-panel.tsx — Sheet panel (the customer-detail-panel idiom): contact, summary (LTV, sessions, own average, last seen, own rhythm), holds now, outstanding, 12-month training bars, enrolment history with ended dates, recorded payments labelled with the date they begin, link to the full student page.
Verified with real Chromium through the OTP flow on a local dev server: tiles, rows and panel values reconciled by hand against the seeded data; document/body horizontal overflow 0 at 1280, 768 and 390; dark mode via the in-app toggle. Every metric definition validated read-only against production (numbers in the PR).
0
v0.53.13Shipped
Academy Collections — detail view, and the Billing affordances that belong on a chasing screen
13 Oct
Nestor: "collections need an upgrade some of the options in billing are better, also no detail view in collections about customer and payments or reminders". Every Collections row now opens a side panel for the customer behind the debt: everything they owe, what they have paid before (with a punctuality sentence that tells a reliable payer having a bad month from a persistent problem), every reminder sent on any of their debts, and every note — with chase, copy link, mark paid and add note beside them. The open debt is mirrored into ?debt=<id> so the panel survives a refresh and can be linked. Mark paid now asks how they paid: the row's one-click call had never succeeded because the settle route refuses mark_paid without a tender. sent_manual is presented as a link handed to staff, never as delivered. Brought across from Billing: tender on mark paid, explicit channel on send, reminder history, Open student. Left in Billing on purpose: Revert to due, Due/Paid/All, Collected, Method and sold-via filters, package-kind filter, inline Remind — record affordances, not worklist ones (DEC-513). The list got thinner for it and no table scrolls sideways: 0px overflow measured at 1280 / 1024 / 768 / 390.
apps/admin/src/app/api/admin/collections/debts/[id]/route.ts (new) — GET one debt → the customer's whole picture at this facility: outstanding requests (OUTSTANDING_STATUSES, derived), per-currency totals, settled history + stats, newest 100 reminders with the true total, notes with author names, channel availability with reasons, template labels. Facility-scoped on every read; 404 for another club's id; 400 past the per-customer ceiling rather than a short history.
apps/admin/src/app/api/admin/collections/_lib/customer.ts (new, unit-tested) — buildPaymentHistory: settled requests matched to payments rows by the Billing feed's rule (same subject, same payer, nearest to paid_at, each payment claimed once), reversal pairs hidden, counter sales without a request kept as their own rows, lateness graded in the facility's calendar day, stats with the late-only mean. Loaders for requests, payments, reminders, notes and author names.
apps/admin/src/components/admin/academy-collections-detail-panel.tsx (new) — the Sheet: contact lines with the closed-channel reason as text, Owes now cards (focused debt ringed) with Chase by <best> / Chase by WhatsApp / Chase by email / Copy link / Mark paid, Has paid before with the punctuality sentence, Reminders sent with an honest status line per attempt, Notes with an inline add form (debt picker when the customer owes more than one). No <Table> inside.
apps/admin/src/components/admin/academy-mark-paid-dialog.tsx (new) — the tender prompt (TenderSelect, no default, optional note) shared by the row menu and the panel; the settle PATCH now carries payment_method and the paid_by_other_method answer is surfaced.
apps/admin/src/components/admin/academy-collections-client.tsx — rows open the panel (click or Open debt), ?debt= seeded on mount and mirrored with history.replaceState; chase carries an explicit channel through dry run AND send; Mark paid goes through the dialog; Open student in the row menu; the panel re-reads after a chase, a settlement or a note. Columns drop by importance (Last contact xl, For lg, Amount/Age fold under the name below sm, bulk select sm) with bounded, truncated, titled names.
apps/admin/src/components/admin/academy-collections-types.ts — detail wire types; CONTACT_STATUS_LABELS reworded (sent → 'Accepted by provider', sent_manual → 'Handed to staff (WhatsApp link)') plus CONTACT_STATUS_EXPLANATIONS; describePaymentPattern / describeAge / describeDaysLate, unit-tested.
Unit tests: collections/_lib/customer.test.ts (13 cases) and components/admin/academy-collections-types.test.ts (8 cases); the suite is 346 green. Verified with real Chromium through the OTP login against a locally seeded population (rolled back afterwards): open, note, chase dry-run on both channels, manual WhatsApp send, mark paid with tender, settled-link reopen, unreachable customer, dark mode, 1280/1024/768/390.
0
v0.53.12Shipped
Academy Billing lists one-time transactions again — drop-in seats, under the Billing contract
13 Oct
Nestor: "Billing is missing one time transactions such as people that pay for one lesson or join events for one time", and on whether drop-ins are members: "it is not important as academy members that we can keep in". The lesson_players drop-in source was removed from this feed on 2026-09-18 (v0.52.6, DEC-391) when it was the packages TAB INSIDE MEMBERS; the feed has since become its own nav entry, /{facility}/academy/billing (v0.53.7, PR #1221) — the club's money screen — so the same rows now belong. NOT a revert: the same data under a contract that moved (DEC-508). Measured read-only on production first: pad-thai-padel holds 2,029 paid, package-less seats worth ฿2,473,200, every one payment_method 'external' (collected through an OpenRally consumer app, tender 'prepaid'), joined 2025-03-03 → 2026-05-29 — nothing since go-live; Bali holds none with money. Events have no entity of their own (classes.class_type is private|group), so one source covers 'one lesson' and 'join an event once'. The tiles move with the feed via migration 20261012190043 (NOT applied to production — Nestor runs it with `db query --linked --file`). Members is untouched.
apps/admin/src/app/api/admin/academy-payments/route.ts — the lesson_players read is back as a fourth source, scoped through lessons!inner (no facility_id of its own), with the three money predicates IN THE QUERY (player_package_id IS NULL, paid_amount > 0, status NULL-or-not-cancelled via .or() because PostgREST's neq drops NULLs) so the fetch window counts transactions and the predicate reads the same as the tile's. payment_requests widens to subject_type IN ('player_package','lesson'); lesson names resolved facility-scoped; a lesson request covers ONE student's seat (lessonCoverageKey), not the whole roster as the pre-removal read did. An unrecognised ?product= is now a 400, unified with the Enrollments route (it used to return an empty feed).
apps/admin/src/app/api/admin/academy-payments/_lib/billing-products.ts (new, unit-tested) — Billing's product vocabulary: the shared package-kinds (three kinds, all, plan/pack aliases) plus `drop_in`, a fourth segment 'Drop-ins'. Deliberately NOT added to packages/common PACKAGE_KINDS or PRODUCT_FILTERS: a drop-in has no template, the Enrollments route keeps 400-ing ?product=drop_in rather than answering an empty list, and billingProductMatches defers to productFilterKinds for every shared value so the two screens cannot classify 'Session Packs' differently (DEC-510). A row whose template could not be resolved is still claimed by no named segment, Drop-ins included.
apps/admin/src/app/api/admin/academy-payments/_lib/feed.ts — FeedSubjectType readmits 'lesson'; FeedProduct is BillingProduct | null; dropInChannel() (external | payment_link | in_club — the pre-removal feed labelled every externally-collected seat 'in club') and lessonCoverageKey(); header rewritten for the Billing contract and for what is still excluded: payments.type='lesson' (the settle RPC writes it beside the seat's paid_amount — reading both counts a settled lesson request twice), package-funded seats (435 on production, all ฿0 — the deductions ledger on this same screen), unpaid package-less seats, memberships, bookings and credit_topup (a wallet prepayment later spent as a package sale this feed already lists) (DEC-509).
supabase/migrations/20261012190043_academy_payments_billing_one_time_transactions.sql — academy_payment_summary(): collected_lessons restored with predicates mirroring the feed one for one, including an anti-join against an OPEN lesson request for the same (lesson, student) — which is what keeps 'Revert to due' on a settled lesson request coherent on this screen, since revert_academy_payment has no lesson branch and leaves paid_amount in place; open_requests widens to ('player_package','lesson'); collected_payments stays type='package' on purpose (DEC-511). NOT APPLIED to production. Verified read-only that the new Collected arithmetic and the feed's paid rows agree for the same windows at both clubs.
apps/admin/src/components/admin/academy-payments-client.tsx + academy-payments-types.ts — product segments come from BILLING_PRODUCT_SEGMENTS (the shared four plus Drop-ins, last); Subject badge 'Drop-in'; channel 'External app' labelled and filterable; row actions: 'Send reminder' / 'Copy payment link' now also hidden when the feed status is paid — a paid package sale had been offered a reminder because it carries no request status, and 2,029 paid seats would have made that the most common row (DEC-512). No new (supabase as any); formatPrice with the row's currency throughout.
Unit tests: _lib/billing-products.test.ts (14 cases — identity with the shared vocabulary by construction, drop_in claims only drop-ins, unresolved rows claimed by no segment, segments partition the classified rows) and feed.test.ts (+7: a template never classifies as a drop-in, channel derivation, per-student lesson coverage).
Docs: DEC-508…512; AC-17 and the out-of-scope note in ac-academy-payments.ts amended append-only to record the restoration and the reason it is not a reversal.
0
v0.53.11Shipped
SEC-01 void and refund attribution
8 Oct
Pablo's Bali operations punch list: create/discount/close/line-add-remove attribution already shipped (PRs #1220, #1227, #1226, #1229, #1230, #1232) — this closes the last pair. pos_orders.voided_by was always auth.uid() (the shared till account, not the PIN-identified staff member who pressed the button); pos_refunds had no actor column at all. void_pos_order/unvoid_pos_order/refund_pos_order_payment gain an optional p_acting_user_id, required once a facility has adopted staff PINs (same gate create_pos_order_atomic already enforces for closing a bill). No new manager-approval column or step — void/refund have always required the actor to hold owner/admin/manager themselves (see decisions.ts for the no-approval-column and trigger-widening calls). pos_resolve_facility_display_names' evidence clause extended (additively) to resolve voided_by/refunded_by_user_id names. Rebased onto #1232's shared CloseAttributionInline/CloseAttributionRows/CloseAttributionListRow components (extended with voidedByName/refundedByName) rather than reintroducing the hand-duplicated JSX those just eliminated.
Migration 20261008060011 — pos_refunds.refunded_by_user_id (additive); pos_resolve_facility_display_names +2 evidence branches (voided_by, refunded_by_user_id); pos_orders_prevent_completed_mutation's voided_by check widened from strict auth.uid() equality to auth.uid() OR any PIN-enrolled staff member at the same facility; void_pos_order/unvoid_pos_order/refund_pos_order_payment each gain p_acting_user_id (same facility PIN-adoption gate as create_pos_order_atomic) and write a best-effort pos_activity_logs row (order_voided/order_unvoided/order_refunded).
apps/terminal: use-void-order-mutation.ts and order-detail-modal.tsx's useIssueRefund/RefundModal pass the PIN-identified acting user (useTerminalLock().identity.userId, same source PR #1227's line-attribution uses) through to the RPCs. void-order-dialog.tsx does the same for void/un-void.
packages/common/src/close-attribution.ts (resolveClosedByNames/resolveClosedByNamesAcross/resolveExportAttribution) extended additively to also resolve orders.voided_by and refunds[].refunded_by_user_id / a refund row's own top-level refunded_by_user_id — one shared batched RPC call, not a second lookup mechanism.
packages/common/src/components/close-attribution-display.tsx — CloseAttributionInline/CloseAttributionListRow/CloseAttributionRows (landed in #1232) extended with voidedByName (all three) and refundedByName (list row + standalone rows) rather than hand-rolling a parallel JSX block beside them.
Both apps' order-detail-modal.tsx: "Voided by <name>" folds into the existing CloseAttributionInline pill; apps/terminal gained a per-payment refund history list (reason/date/"Refunded by <name>"), apps/pos's existing read-only Refunds section gained the same attribution line. Both apps' Transactions list rows (CloseAttributionListRow) and standalone transaction-detail pages (CloseAttributionRows) gained voidedByName/refundedByName; CSV export (TRANSACTIONS_CSV_HEADER, packages/common/src/pos-orders.ts) gained "Voided By"/"Refunded By" columns.
apps/pos's useVoidOrder/useUnvoidOrder now pass the logged-in manager's own id as p_acting_user_id — apps/pos has no PIN-identity concept, and once this shared facility adopts staff PINs, void_pos_order's new gate would otherwise reject every apps/pos void outright (found while verifying: apps/pos/e2e/void-order.spec.ts started failing against a PIN-adopted facility before this fix). Not a lie — there is no other identity apps/pos could attribute to, and it's the same value voided_by already fell back to via COALESCE(p_acting_user_id, auth.uid()).
apps/terminal/activity-logs/page.tsx gained ACTION_CONFIG entries for order_voided/order_unvoided/order_refunded.
New e2e spec, apps/terminal/e2e/void-refund-attribution.spec.ts — drives the real PIN pad: locks the terminal mid-session and unlocks it again with a SEPARATE staff member's own PIN (not the till's own login), then voids/refunds a bill and asserts "Voided by"/"Refunded by" shows that staff member's real name, not the shared till account's — both at the UI layer and directly against voided_by/refunded_by_user_id in Postgres.
2 E2E tests
v0.53.10Shipped
Category/group filter on Transactions (TRX-03)
8 Oct
Pablo's Bali operations punch list, last remaining piece of the Transactions filter set (search/date/method/status already shipped — ALB-79, ALB-88, ALB-89): the Transactions page had no way to filter by item category/group. A payment matches when its order has ANY line item in ANY of the selected categories (EXISTS semantics — DEC-502), combinable with every existing filter. search_pos_transactions and pos_transactions_summary gain an additive p_category_ids uuid[] param (DROP+CREATE, not CREATE OR REPLACE — see the migration's own header comment on why an added trailing param needs the former). The two direct-query paths that don't go through either RPC (the browse/infinite-scroll list and both CSV export fetchers) resolve the identical EXISTS match client-side as an order-id (or, for the refunds export, a payment-id — see DEC-502) allowlist instead.
supabase/migrations/20261008061353_pos_transactions_category_filter.sql — p_category_ids uuid[] default null added to search_pos_transactions and pos_transactions_summary via explicit DROP + CREATE (CREATE OR REPLACE would silently create a second, ambiguously-overloaded function instead of replacing the first — verified, not assumed). Matched via `exists (select 1 from pos_order_items oi where oi.order_id = o.id and oi.category_id = any(p_category_ids))`, ANDed into both functions' existing filter chain (including pos_transactions_summary's separate refunds aggregate).
apps/pos + apps/terminal's use-transactions.ts — categoryIds threaded through useTransactionsInfinite, useTransactionsSummary, useTransactionsSearch (all four RPC/direct-query paths named in the plan). useTransactionsInfinite and both CSV export fetchers (which query pos_payments/pos_refunds directly, never through either RPC) resolve a category-matching order_id allowlist via a new fetchOrderIdsByCategory helper, applied as a `.or()`-chunked `.in("order_id", ...)` — same 150-id chunking convention as the existing SEARCH_ID_CHUNK. fetchAllRefundsForExport resolves a payment_id allowlist instead (pos_refunds has no order_id column, and `.or()` rejects a dotted embedded-resource path like payments.order_id — a real PGRST100 parse error hit and fixed during this build, not a hypothetical).
apps/pos/src/components/transactions/category-filter-popover.tsx + apps/terminal's identical copy (new) — a popover multi-select chip reusing useCategories (the same hook the discounts category-assignment flow already uses), placed alongside the existing method/status filter chips on the Transactions page, never replacing them.
packages/database/src/database.types.ts — p_category_ids?: string[] added to both RPCs' generated Args types (hand-patched: a full `supabase gen types` regen would have pulled in unrelated, uncommitted schema changes from other concurrent worktree sessions sharing this machine's local Supabase instance).
New e2e specs, apps/pos/e2e/transactions-category-filter.spec.ts + apps/terminal's parity copy — two brand-new, randomly-generated pos_categories rows per run (collision-proof against the ~70+ other concurrent worktree sessions sharing this local DB, unlike a name/text-based fixture). Proves: selecting a category shows only matching orders; combines with AND (not OR) against the date range (an old matching order stays hidden under the default range, then appears once the range widens to include it, with the category filter still applied throughout); the CSV export respects the filter.
4 E2E tests
v0.53.9Shipped
Proactive security sweep: SEC-01's facility-wide PIN gate had no friendly-error mapping
8 Oct
Standing-authority security/bug sweep across the ~36 hours of concurrent SEC-01 work (#1188–#1230). Surveyed create_pos_order_atomic's facility-wide PIN gate (DEC-458), the terminal-lock-provider rollout-safety logic, void/refund attribution scope, and recent RLS widenings — see decisions.ts DEC-501 for the full write-up. One concrete, fixed finding: neither app's friendly-error.ts mapped the two SEC-01 RAISE EXCEPTION messages ("this facility requires a PIN-verified identity to close a bill" / "bar staff cannot close a bill"), so either message fell through to raw RPC text shown to staff — the exact "[object Object]"-class incident this file's own header exists to prevent. Also surfaced, NOT fixed (flagged to Alba — needs a product call, not a bug fix): apps/pos retains a fully live but unlinked-from-nav /terminal checkout route (own CheckoutDialog, own useCreateOrder) that has never been wired to supply p_acting_user_id — the moment any facility turns pin_lock_enabled on, every checkout through that orphaned route will hard-fail with no PIN-entry UI available to resolve it there.
apps/terminal/src/lib/friendly-error.ts + apps/pos/src/lib/friendly-error.ts — added matchers for both live SEC-01 RAISE EXCEPTION messages from create_pos_order_atomic. apps/terminal's message points staff at reloading + re-unlocking with their PIN (covers the documented up-to-2-minute stale-cache race after another device flips the toggle, per terminal-lock-provider.tsx's own header comment). apps/pos's message explains the till requires a staff PIN and directs staff to the terminal app, since apps/pos has no PIN-entry UI to resolve it in place — a safety net for the orphaned-route finding above, not a fix for it.
apps/terminal/src/lib/friendly-error.test.mts — two new unit tests asserting both messages render actionable, non-raw-RPC text.
0
v0.53.8Shipped
POS Discounts report shows the bills behind the totals (REP-03)
7 Oct
Pablo's Bali operations punch list: Payments → Discounts only showed totals per discount name — finding WHERE a discount was given meant hunting through Transactions bill by bill. A new "Discounted bills" table sits below the existing totals: one row per discount application (date, customer, staff, discount, %, amount, bill total), filterable by name/type/scope/source, click-through to the real OrderDetailModal (which already shows "<discount> — <item name>" per line, so which items a discount hit comes for free), and a third CSV section. No migration — all the data already existed. See DEC-497 for the "discount category" resolution and why every %, not just fixed-type ones, renders with ≈.
apps/pos/src/queries/use-discounts-report.ts (new) — useDiscountsReport (the existing aggregate-by-name query, extracted out of page.tsx unchanged) plus useDiscountedBills: one row per pos_order_discounts record, same order-selection basis (REPORTED_ORDER_STATUSES, date/location filters) as the aggregate by construction so the two totals foot. Resolves customer/staff/display-number the same way use-item-sales-lines.ts does; batches item-level discounts' item_name + total_cents (itemNames[], % basis) instead of N+1.
apps/pos/src/lib/discounts-report.ts (new) — pure types/helpers: normalizeDiscountType (percent|percentage → percent, everything else → fixed), scope/source derived from order_item_id/discount_id (pos_order_discounts has no scope column of its own), filterDiscountedBills (AND across dimensions, OR within one — same convention as item-sales.ts), discountNameOptions.
apps/pos/src/components/reports/discounts-bills-filters.tsx (new) — Discount/Type/Scope/Source multi-select chips, reusing item-sales-filters.tsx's MultiSelect (now exported) instead of a second copy of the same popover.
reports/discounts/page.tsx — "Discounted bills" table under the existing Discounts totals (before Returns), row click opens the existing OrderDetailModal with prev/next across the filtered rows (same pattern as reports/item-sales/detail/page.tsx). Filters live in plain component state, not the URL — this page doesn't persist its date range in the URL either, so only wiring the new filters in would have been a half-step; deliberately avoids touching use-report-url-state.ts (shared by every other report page, incl. REP-04's in-flight date-range work) entirely.
CSV export (downloadDiscountsReturnsAndBillsCsv) — third "Discounted bills" section added to the existing Discounts/Returns export, using the same (possibly filtered) rows the screen shows.
New e2e spec, apps/pos/e2e/discounts-report-bills.spec.ts — seeds 3 discount shapes (bill-level preset percent, item-level manual fixed, bill-level manual with the 'percentage' spelling) plus an undiscounted control order directly via the service-role client (established pattern, see demo-split-bill-discount-fix.spec.ts); proves the control is excluded, derived % matches hand math, the per-bill total foots against the aggregate total, all four filter dimensions, modal click-through, and the CSV's new section — scoped throughout to this spec's own uniquely-named rows (not raw row counts) since the local Supabase instance is shared across ~70 concurrent worktree sessions.
1 E2E tests
v0.53.7Shipped
Billing becomes its own Academy destination, Sessions leaves the menu, and no admin table clips its own content
7 Oct
Nestor: "let's move sessions and billing tabs in members as independent ones in the menu in fact sessions exists already as lessons so we don't need it just add billing below members", and separately "make sure the tables have no overflow on the right". Academy → Members is single-purpose again (enrolments only, no tab bar); Billing is a nav entry directly below it at /{facility}/academy/billing; Sessions is gone from the nav with its package-deduction ledger preserved as a collapsed section ON Billing, because deductions taken and never returned are money (DEC-492). 🔴 ?tab= WAS A PUBLISHED DEEP LINK AND IS NOW A ROUTING DECISION, read server-side by one shared helper and verified at the HTTP layer. On the tables: the shadcn primitive already self-wraps every <Table> in overflow-x-auto, so the real defect was elsewhere — seven RAW <table> elements in the CRM sat inside overflow-hidden shells that CLIPPED their columns with no scrollbar at all.
ACADEMY → BILLING IS ITS OWN ROUTE, /{facility}/academy/billing — not /academy/payments, deliberately. The label is Billing, and this nav has already paid once for letting the label and the address disagree (v0.52.0 called an entry Members while leaving it at /academy/subscriptions). New sidebar entry directly below Members with a currency-neutral Banknote icon, never a $ — clubs bill in THB and IDR. New staff-docs guide at /academy/billing, with the Members guide rewritten and the Academy overview relinked.
🔴 THE REDIRECT MAP, WHICH IS THE RISKY HALF. ?tab= no longer selects a panel; it selects a ROUTE, read once server-side by src/lib/forward-search-params.ts, shared by all three paths that can receive it. `payments` and `sessions` reach Billing, everything else resolves to Members, the now-dead `tab` key is stripped at the redirect, and every other param survives verbatim INCLUDING repeated keys — ?view=, ?overdue= and ?open=<payment_request_id> (what a payment-request notification links to) all arrive intact. ?tab=sessions additionally lands as ?section=deductions with the ledger open. /academy/payments keeps meaning the money screen when no tab is named (the retired withDefaultTab's job, now an options.defaultTab), while an explicit ?tab=members on it still wins. /academy/members only bounces a BILLING tab — ?tab=members and unrecognised tabs render in place rather than taking a hop that lands where they already are. Neither redirect chains: both destinations answer 200 directly. 20 unit tests on the helper; 19 assertions driven through real Chromium reading actual Location headers.
SESSIONS LEFT THE NAV, THE DEDUCTION LEDGER DID NOT (DEC-492). AcademySessionsLedger is re-mounted on Billing as a COLLAPSED "Package deductions" section — collapsed so Billing stays the payments screen and so the ledger's fetch does not run on every visit. api/admin/package-transactions/* (route, _lib/ledger.ts, ledger.test.ts) is entirely untouched and still tested. The premise "sessions exists already as lessons" is true of the SCHEDULE and false of the LEDGER: Lessons is what is coming up, this is which package paid for each past session and which cancelled lessons never returned the student's credit — 15 unambiguous production cases plus 4 ambiguous, i.e. money the club kept by mistake.
🔴 SEVEN RAW CRM TABLES WERE CLIPPING, NOT SCROLLING — the actual table bug. components/ui/table.tsx already wraps every shadcn <Table> in `relative w-full overflow-x-auto`, so all 31 of those inherit a scroll container for free. The seven raw <table> elements under app/[facility]/crm (customers list, customer detail ×4, campaign sends, segment members) had none, and each sat inside a `rounded-* border overflow-hidden` shell — so anything past the viewport was simply GONE, with no scrollbar to say it existed. Clipping is strictly worse than scrolling. Each now carries an inner overflow-x-auto inside its rounded shell, the same shape the primitive uses, with the scroller wrapping the table only so pagination bars stay put. Verified: zero clipped tables at 1280/768/390.
LONG STRINGS BOUNDED AT THE CAUSE, NOT HIDDEN BEHIND A SCROLLBAR. Every column that held an unbounded free-text string now has a max-width, truncation and the full value on `title`: emails and phones in the three raw CRM list tables, team-list's email, lesson-roster-panel's email/phone and both feedback cells (one of which is the app's only whitespace-pre-wrap cell and now breaks words too), all four free-text columns of the session ledger, package-session-activity's lesson link and staff, player-subscriptions-list's player and package, and academy-payments-client's subject. Measured effect: Members went from 161px of table overflow at 1280 to ZERO, Billing from 309px to 82px. package-session-activity also gained the `rounded-md border` frame every other table has, and reports-dashboard lost a redundant outer overflow-x-auto that could never engage.
THE GLOBAL whitespace-nowrap WAS LEFT ALONE, AS A DECISION (DEC-491 rationale). TableHead and TableCell hard-code it, which is what makes cells force width rather than wrap — the highest-leverage knob and the riskiest. Flipping it would re-wrap ~31 tables and ~200 columns app-wide, trading a contained horizontal scroll for unverifiable row-height churn everywhere, which is a far larger blast radius than the defect. Targeted max-w + truncate fixes the same cause (unbounded string width) on the columns that actually have one. No responsive column-hiding convention was introduced either: there is NO such idiom anywhere in apps/admin today, so adding one would be establishing a convention rather than following it, and which column is 'least important' is a per-screen product call.
🔴 FOUNDATION FINDING, REPORTED NOT FIXED: at 390px the admin SHELL, not any table, is what scrolls sideways. AdminSidebar is a hard `w-48` (192px) with no breakpoints, so a 390px viewport leaves <main> just 198px — less than the minimum width of controls that predate this branch. settings/reports overflows 208px at 390 WITH NO TABLE ON IT AT ALL, which is the proof. document and body never scroll horizontally at any width (measured 0 everywhere, all three widths); it is <main> that does, at 390 only. Making the sidebar responsive is a change to the primary navigation of the whole admin app and is deliberately NOT bundled into a table-overflow branch. One real narrow-width bug WAS in scope and is fixed: the CRM customers filter row overflowed 15px at 768 because a flex-1 search box would not shrink under its content (`min-w-0`) and the pill group could not wrap (`flex-wrap`) — now 0.
0
v0.53.6Shipped
Academy Planning occupancy shows coaching court time, per coach — not the whole club
5 Oct
Academy > Planning reused the Calendar's occupancy badge, which counts every booking club-wide, so every coach tab read the same figure (e.g. 64% · 861.5h/1350h). The badge now shows coaching court time scoped to the selected coach, over the unchanged court-capacity denominator. The Calendar badge, /api/admin/reports and the business-report SQL are untouched. Also closes a hole where PATCH /api/admin/lessons/[id] could edit a cancelled lesson.
GET /api/admin/occupancy accepts optional category=coaching[&coach_id=<uuid>] (validated by a pure parseOccupancyScope helper, 5 unit tests). Coaching scope sums lesson-linked bookings only, excluding hold/expired/cancelled AND no_show bookings and cancelled lessons; the unscoped path is unchanged and still counts no_show
CalendarOccupancyBadge takes optional category/coachId; Day/Week/Month grids opt in via scopeOccupancyToFilter (Planning only), badge reads 'coaching' with a coach-specific tooltip and exposes data-scope. New e2e TC-299
PATCH /api/admin/lessons/[id] returns 409 'Lesson is cancelled' for any edit of a cancelled lesson (re-cancelling stays an idempotent no-op)
1 E2E tests
v0.53.5Shipped
Three-way package taxonomy — session packs, credit packs, recurrent plans (and the form that was corrupting live data)
1 Oct
Nestor: "technically we have 3 type of packages: recurrent plans, credit packs and session packs" and, separately, "update enrollemnt filters with new categorizations we have for packages please". lesson_packages.package_type becomes the SINGLE DECLARED AUTHORITY for the kind — widened additively to {'session_bank','credit_pack','recurring'} and bound to the cadence by a coherence CHECK, (package_type = 'recurring') = (billing_period IS NOT NULL). No new column, legacy value names kept (the public v1 API returns package_type verbatim), display names mapped in one shared constant. 🔴 THE URGENT HALF SHIPS ALONE AND FIRST: the package template form submitted `billing_period: packageType === 'recurring' ? billingPeriod : null` on every save, so opening production's 'Growth' template (package_type 'session_bank' WITH billing_period 'monthly') and pressing Save nulled the cadence of a plan carrying 4 ACTIVE enrolments and 2 DRAFT payment_requests already raised for 2026-10-01 and 2026-10-07 — orphaning issued invoices, with no error, because the write was valid SQL. Closes DEC-415, flagged-not-fixed since 2026-09-17. See DEC-460 (declared not derived, and why), DEC-461/462 (the rejected column and rename), DEC-463 (the form), DEC-464 (why read-tolerance must merge before the migration), DEC-465 (one filter definition, two copies retired), DEC-466 (billing_cycle_days frozen), DEC-467 (the NOT VALID choice, argued).
🔴 THE FORM NO LONGER CORRUPTS LIVE DATA, and it stands alone. The two cadence keys are now OMITTED rather than nulled for a non-recurring type — `...(packageType === 'recurring' ? { billing_period, billing_mode } : {})`. Safe on both verbs and provably sufficient: PATCH's allow-list writes only keys that are PRESENT, POST defaults both when missing, and PATCH does not accept package_type at all, so the kind cannot change through this form and a save can never be the moment a plan stops being a plan. Belt as well as braces: a server guard 409s an explicit `billing_period: null` on a template that has a cadence and active enrolments, naming the template, the student count and the cycle; a failed enrolment count returns 503 rather than proceeding on an assumption that is destructive when wrong. The predicate is a pure function with 8 unit tests pinning the undefined/null distinction in BOTH directions — conflating them either re-opens the bug or makes every live plan permanently uneditable. No migration, no DB change, admin-only, revert-safe.
A THIRD INSTANCE OF THE SAME BUG, found and fixed while here: `valid_days` was gated on `packageType === 'session_bank'`, so saving either of production's two credit packs would have NULLED its validity (MASTER PACK 180 days, the Arnav pack 90) the moment the migration declared it a credit_pack — the same silent data loss, one field over. Gate is now `!== 'recurring'`, and the Validity input shows for both non-recurring kinds, in step with it.
READ-TOLERANCE MERGES BEFORE THE MIGRATION (DEC-464). Both v1 gates were EXCLUSIONS — `package_type !== 'session_bank'` on v1/lesson-packages/[id]/purchase and on packages/[id]/payment-link — so a third legal value would have been rejected by both, making every credit pack unpurchasable and un-payment-linkable in the gap between merges. Both are now INCLUSIONS on 'recurring', which is what they always meant, so they fail SAFE for a future fourth kind and match /subscribe's gate (already an inclusion). POST /api/admin/lesson-packages' hand-written validTypes list is replaced by the shared isPackageKind, so the route can no longer reject a value the database accepts.
ONE DEFINITION OF 'WHAT KIND IS THIS' (DEC-465). packages/common/src/package-kinds.ts holds the three kinds, both label maps, the `product` vocabulary, the alias resolution and the shown segments — 16 unit tests, including that the aliases equal the union of the named filters they stand in for and that the three kind-segments PARTITION the estate. The Enrollments filter and the Payments feed each held their own copy of the plan/pack rule and both read billing_period, which is why both filed 'Growth' under Recurrent Plans while every package_type-reading screen badged it a session bank. Both now import the one definition; neither declares a vocabulary. Legacy aliases kept so bookmarked ?product=plan / ?product=pack links keep working — 'pack' spans BOTH non-recurring kinds, which is what makes it faithful.
THE ENROLLMENTS FILTER IS THREE-WAY, still server-side. The predicate moves from `billing_period` to `.in('lesson_packages.package_type', productFilterKinds(product))`, applied BEFORE the count and the page slice (production holds 206 enrolments for one club, so the browser never has the set). Every existing rule survives: `!inner` on the embed for a named product — without it PostgREST empties the embed instead of dropping the parent row, and every segment would show everything; an unrecognised value is a 400, never a silently widened or emptied result; and under 'All' the embed stays an OUTER join, so an enrolment whose template cannot be resolved stays visible and is claimed by no segment. Because the coherence CHECK makes the old and new predicates equivalent for the recurring case, the set of rows the old 'plan' segment returned is UNCHANGED — the former 'pack' segment splits into two.
THE PAYMENTS FEED CHANGES IDENTICALLY, and no third copy was created. packageProduct() now classifies from the template's declared kind instead of its billing period; package_type joins both lesson_packages embeds; FeedProduct becomes PackageKind | null. The undefined/unknown split is preserved and re-pinned: an unresolved template is null, never coerced to 'session_bank' (the column default, and the tempting wrong answer), and an unrecognised value is null too rather than being passed through to match no segment and vanish. `cadenceOf` still reads billing_period, deliberately (DEC-411) — that is a question about when money moves, not what the product is. ⚠️ FLAGGED, NOT CHANGED: this route answers an unrecognised product with an empty feed while Enrollments 400s. Pre-existing, documented on each side, and an API contract change that does not belong in this commit.
THE MIGRATION IS WRITTEN AND DELIBERATELY NOT APPLIED. Order: widen the package_type CHECK → backfill five rows → pre-check → ADD the coherence CHECK NOT VALID → VALIDATE. One transaction. Reversing any pair fails: M1 must precede M2 because M2 writes 'credit_pack', and M2 must precede M3 because three rows contradict the coherence CHECK today. The five ids and their prior values are in the header — that header IS the rollback script — and billing_period is never written, so nothing about the money changes. Each UPDATE is guarded by its expected prior state with three distinct outcomes: absent → NOTICE and skip (every dev's local and every fresh db reset; a hard failure would break `db reset` for everyone), already at target → NOTICE and skip (second application is a no-op), present but wrong → EXCEPTION naming found vs. expected. The template name is used only in messages, never as a guard, so fixing the 'Invidivual' typo cannot abort a deploy.
THE CENSUS WAS RE-DERIVED, NOT TRUSTED — and it agrees, with one correction. 43 templates classified read-only against production 2026-10-01 by the stated rule (item key = coalesce(class_type,'') || ':' || coalesce(class_id::text,''); one distinct key = session pack, two or more = credit pack, any cadence = recurrent plan). 36 session_bank stay, 2 recurring stay, 2 recurring → session_bank (claimed recurring with no cadence, would never bill), 2 session_bank → credit_pack (2 and 3 distinct item keys), 1 session_bank → recurring ('Growth'). After: 38 + 3 + 2 = 43, zero coherence violations, zero templates with no items so no ambiguous case needs a human. CORRECTION to the briefed figures: 'Growth' has 4 ACTIVE enrolments (27 total), not 5 — and it already carries 2 DRAFT payment_requests, so the stakes were higher than stated.
VERIFIED, AND HOW. The migration body ran TWICE inside one BEGIN … ROLLBACK against local, after seeding exact replicas of the five production rows in their PRIOR states plus two control rows, so the real UPDATE path was exercised and not just the skip path: pass 1 rewrote all five, pass 2 reported every one 'already <target>, skipped', assertions passed after both. The assertions check the final types, that billing_period and valid_days are untouched, zero coherence violations, that the constraint is convalidated, that the widened CHECK really admits an inserted 'credit_pack', and that the coherence CHECK really REFUSES a newly inserted session_bank+monthly — the Growth shape — going forward. The drift guard and the pre-check were both proven to fire with their intended messages. A post-check OUTSIDE the transaction confirmed nothing persisted: 0 rows, no coherence constraint, the package_type CHECK back to its original two values, the original billing_cycle_days comment intact, 180 public tables. Local was never reset.
billing_cycle_days FROZEN, NOT DROPPED (DEC-466). NULL on all 43 templates and duplicated by valid_days and billing_period. It turned out to be not merely unused but UNWRITABLE — absent from POST's insert block and from PATCH's editableFields allow-list — which is why every row is NULL and why removing the write is behaviour-preserving. The form's 'Billing Cycle (days)' input goes with it: a control that claimed to set when subscriptions renew and had never changed one row is worse than no control. Also dropped from the dashboard subscriptions-due select, which selected it and package_type and read neither. NOT dropped as a column: destructive DDL on live data is banned, and two RPCs plus the player_packages → player_subscriptions mirror still reference it behind COALESCE defaults that the NULLs already take.
⚠️ REPORTED, NOT FIXED (stop-and-report). `enrollment_model` and `auto_renew_default` in the same form card are the SAME dead keys, accepted by neither verb and silently dropped — flagged in a code comment. apps/web's package-card.tsx and use-lesson-packages.ts still hardcode the two-way label and union and will mislabel a credit pack, but apps/web is PARKED so it is reported rather than touched. Out of scope by instruction and untouched: the mirror's next_renewal_date bug (20260916203446) and the 52 recurring enrolments sitting at status 'exhausted' — both belong to the billing work. NOT VISUALLY VERIFIED: browser tooling was unavailable for this session, so no screen was rendered.
v0.53.3Shipped
Academy Members — Billing first, and no chasing a row that is already paid
1 Oct
The Academy half of the period work, rebased onto the unified date range picker (v0.53.1). This branch invented its own segmented pill and its own resolver; the pill is gone and the resolver was adopted upstream as lib/date-range.ts, so what ships here is everything the picker did NOT cover: the Members tabs reordered to Enrollments → Sessions → Billing with Payments relabelled Billing and its state switch defaulting to All, debt-chasing actions hidden on rows the server already refuses to chase, the `exhausted` chip the Enrollments filter was silently missing, a filter whitelist that answers 400 instead of an empty list, and the ONE screen the picker left untouched — Enrollments, which gets the shared control plus the interval-overlap route semantics that make a Month-to-date default safe on it. See DEC-456 (what was dropped to the shared picker), DEC-457 (settled-row gating), DEC-458 (overlap, not start date), DEC-459 (the missing chip and the whitelist behind it), DEC-460 (the tab reorder and the redirect it would have retargeted).
IT DRAWS THE SHARED PICKER NOW, AND CARRIES NO DATE CODE OF ITS OWN (DEC-456). lib/academy-period.ts, its 24 tests and components/admin/academy-period-filter.tsx are deleted; Billing, Sessions and Lessons take the picker exactly as v0.53.1 ships it, and Enrollments joins them with `useDateRange(timezone)` + `<DateRangePicker testIdPrefix="player-subscriptions">`. The resolver's behaviour survives — it IS lib/date-range.ts, ported case for case and extended to 32 tests — so nothing in the date arithmetic this branch measured was lost; only the pill and its This month / This year vocabulary were.
🔴 AN ENROLMENT OVERLAPS A PERIOD, IT DOES NOT HAPPEN IN ONE (DEC-458). The other screens filter an event by its one timestamp; an enrolment is an interval, so the route filters `starts_at <= periodEnd AND expires_at >= periodStart`. Measured on production before choosing, read-only: only 16 of the 81 active enrolments STARTED this month, so a start-date filter under the Month-to-date default would have hidden 65 of 81 active members on what is now the landing tab. Both columns confirmed NOT NULL with zero nulls across all 207 rows, because a null on either side of a range comparison drops the row silently. This is why Enrollments was left out of the unified picker's own change (DEC-432) — the control and the route semantics had to arrive together.
NO DEBT-CHASING ON A SETTLED ROW (DEC-457). Nestor: “if a payment is paid we should not allow senidng reminders or anything”. Send reminder and Copy payment link were rendered for EVERY row including paid; the server refused and said nothing, which reads as a broken product. Both are now hidden on paid, cancelled and expired — gated on SETTLED_STATUSES, the one list the server refuses on, in a dependency-free module both sides import so the two can never drift. 🔴 It could NOT be gated on the feed's own status: an expired request renders as feed status ‘overdue’ (the LINK lapsed, not the debt), so the row now carries the raw request_status. Reminder history stays — it is a read. Revert to due stays — it applies precisely BECAUSE the row is settled. The reminder panel is gated too, since it is reachable on a paid row and a row can settle while it is open.
ENROLLMENTS CAN FINALLY REACH EVERY STATE A ROW CAN BE IN (DEC-459). Nestor: “in the filters of enrollments please add exhausted too we don't have it”. Census, read-only on production: active 111, EXHAUSTED 73, cancelled 13, expired 10 — 86 of 207 enrolments, over a third of the book, unreachable through the filter. Chips are now Active · Expiring soon · Exhausted · Expired · Cancelled. The deeper fix: `status` and `payment_status` were a bare .eq() with NO whitelist, so an unrecognised value returned an empty list rather than a 400 — which is exactly why a missing chip was invisible instead of loud. Both now validate against the column's own CHECK constraint and answer 400, matching the `product` parser one filter over on the same route.
MEMBERS TABS REORDERED, PAYMENTS RELABELLED BILLING (DEC-460). Nestor: “payments should go to the right and first enrollment, Sessions then Payments can be called billing … this won't be a section to track due payments”. Order is now Enrollments → Sessions → Billing, Enrollments is the landing tab, and Billing's Due/Paid/All switch defaults to All. 🔴 THE TAB KEYS AND TESTIDS ARE UNCHANGED — ?tab= is a published deep link. A regression was caught in the process: /academy/payments forwarded an empty query and relied on the money tab BEING the default, so after the reorder every bookmark of the address named after the money screen would have silently landed on Enrollments. A `withDefaultTab` helper now pins ?tab=payments on that redirect ONLY unless the caller named a tab, with three unit tests on the helper.
v0.53.4Shipped
Academy Collections — the club's accounts-receivable desk
18 Sep
A new Academy destination, below Planning, devoted entirely to chasing due payments over email and WhatsApp. Members → Billing is the complete record of academy money — what happened. Collections is the other question: who owes us, how long, and what have we done about it. It opens on EVERY outstanding debt, aged rather than chronological, oldest-and-largest first, because a month window would hide exactly the row that most needs a phone call. 🔴 It will be EMPTY until the recurring-billing migration is approved and applied — payment_requests has zero rows on production today — so every request-shaped path here is built against the schema and exercised against seeded local data, never against production.
AGED, NOT CHRONOLOGICAL. Buckets are not_due / 1-7 / 8-30 / 31-60 / 60+ plus a separate no_due_date, decided in the FACILITY's timezone. The 30/60/90 ladder every accounting package ships was considered and rejected: Xero and QuickBooks REPORT, while Chaser and Satago — which chase — both break the first month down, because the first week past due is where a message still recovers the money near par. Outstanding totals are shown PER CURRENCY, never summed: these clubs run THB and IDR.
THE TOTAL AND THE ROWS ALWAYS DESCRIBE ONE POPULATION. bucketFor() (what a row is labelled) and bucketDateRange() (the due_date predicate pushed into PostgREST before the count and the page slice) are exact inverses, round-tripped over a ±400-day window, with a second test proving the ranges cannot overlap so no debt is counted twice. An unrecognised filter value is a 400, never a silently widened or emptied result.
A SETTLED DEBT CANNOT REACH THE SCREEN. SETTLED_STATUSES moved to a leaf module (reminders.ts re-exports it, so every existing import is unchanged) and OUTSTANDING_STATUSES is DERIVED from it, so the two cannot drift. There is no paid row on which to offer an action — unlike Billing, which offers 'Send reminder' on paid rows today and lets the server silently skip it.
CHANNELS DEGRADE HONESTLY. Reachability is resolved server-side per row using the same rules sendReminder() applies, and each channel comes back as {available, reason}. No phone number means WhatsApp is not offered and the greyed control says why — quoting the junk value, so 'ask at desk' on a profile is actionable. 'Unreachable' is a filter and a tile in its own right, because a debt nobody can be messaged about is a DATA problem needing a different action.
BULK CHASE WITH A CONFIRM THAT CANNOT LIE. The confirm dialog is a server-side dry run: the same resolution code that will send names every person who will be contacted, on which channel, with which letter, and separately names everyone who will be skipped and why. Capped at 50. Sends are sequential and reported per row — one broken phone number cannot abort the other 49, and a half-succeeded batch is never reported as a failure, because those messages cannot be un-sent.
ESCALATION IS REAL, AND THE CLUB OWNS THE WORDING. Four template rungs (collections_upcoming / _due / _overdue / _final) seeded as SYSTEM DEFAULTS for both channels, editable per club through the existing Setup → Messages manager. No customer-facing copy is hardcoded. A request raised by the recurring biller is UPCOMING for its first ~14 days (raised 7 days before its cycle, due_date = cycle + 7) and is pinned as such by test, so recurring plans are not painted red on the day they are created.
🔴 THE WHATSAPP TRUTH. The manual wa.me lane WORKS TODAY and needs no credentials — the operator clicks a pre-filled link, and the reminder is recorded as 'sent_manual' (handed to a human), never as delivered. whatsapp-cloud.ts was a stub that threw; it is now a real Cloud API implementation, selected per club by a new academy_payment_settings.whatsapp_provider column defaulting to the manual lane, so no deploy can flip a club onto automated sending. It still CANNOT send: it needs WHATSAPP_CLOUD_ACCESS_TOKEN, WHATSAPP_CLOUD_PHONE_NUMBER_ID, a verified Meta Business account, a dedicated number and approved templates — and it throws a message naming all of them rather than pretending.
🔴 A LATENT FOREVER-LOOP CLOSED IN THE DISPATCHER. dispatch_payment_reminders selected queued reminders REGARDLESS OF CHANNEL and posted them to send-payment-reminder, which is email-only: it answers 400 for a WhatsApp row and returns BEFORE the claim-before-send UPDATE, so the row stayed 'queued', was never marked failed, and would be re-posted every hour forever while the club believed a reminder was pending. Unreachable today only because process-package-billing holds AUTOMATABLE_CHANNELS = ['email'] — a one-line narrowing. Now fail-closed: a due reminder on a channel with no automated transport is marked 'failed' with a readable reason, and only email is dispatched.
NO NEW CADENCE, DELIBERATELY. The existing schedule (reminder_offsets + enabled, already false by default and already fail-closed) stays the only one; a second beside it is the drift this project keeps paying for, and payment_requests has zero rows to test one against. Collections chases when an operator presses send. Conversation notes get their own table, collection_notes, keyed on the DEBT — payment_reminders records messages SENT and crm_notes records notes about a PERSON, and neither is the right home.
IT DRAWS THE SHARED DATE RANGE PICKER, WITH TWO DELIBERATE DEPARTURES (DEC-430). The screen's own due_from/due_to input pair is deleted and replaced by the one admin-wide control, the same one Billing, Sessions, Lessons, Bookings and the Operations log draw. 🔴 Its DEFAULT is All time, not Month to date — this desk opens on every outstanding debt because the oldest is the most important and a month window would hide exactly that row — so it opts into `allowAllTime`, the state built for lookup screens where a bounded default suppresses the row the operator came for. 🔴 And it does NOT set `pastOnly`, unlike every other ledger: a DUE DATE is legitimately in the future (the not_due bucket and the collections_upcoming rung exist for debts not yet payable), so clamping the upper bound to today would make the entire not-due half of the screen unreachable through its own filter. The route needed no change — it already took this due_from/due_to shape.
Unit tests (node:test): 41 new across aging buckets, the bucket/range round trip, channel selection and degradation, the settled-status guard, the escalation ladder, and the WhatsApp Cloud config gate, request body, positional parameter order and whitespace sanitiser. Verified after the rebase onto the unified picker: the whole admin unit suite passes — 249 tests at this base (b5af6d58), 0 fail; tsc --noEmit clean on apps/admin and apps/docs (both exit 0). The migration was written and is NOT applied — not to production and not to the shared local database, so its pre-rebase local transaction check was NOT re-run here. NOT visually verified — browser tooling was blocked all session, so the picker's All-time default on this screen, the aging tiles and the bulk-chase confirm still need a reviewer's eyes.
0
v0.53.2Shipped
Dashboard date filtering — and the 1,000-row revenue truncation it exposed
1 Oct
The Dashboard was the last admin screen with NO date filtering at all: four tiles and the court table welded to a rolling 7 days, both charts to a rolling 30, every window ending at whatever instant the page was opened, so the screen answered a question no other screen answers and could not be asked about September. Nestor: "the dashboard has no fucking date pickers, using our new design system the dashboard should have such". This applies the v0.53.1 picker unchanged — configured through useDateRange({ pastOnly: true }), defaulting to Month to date, never unbounded — and does the per-figure honesty work behind it. Unlike Billing, EVERY figure here can honestly follow the period (all four tiles, both charts, the court table are flow measures over one booking window), so nothing carries a "· all time" label; what the screen needed instead was the caveats that can still mislead stated on screen: Revenue is the value of sessions PLAYED in the period, not cash collected (so it will not match Billing's Collected), Utilization is a share of ELAPSED opening hours, and a period running up to now is "so far". Adding the range also exposed two real bugs, both fixed here: the route read its window unpaged against PostgREST's max_rows = 1000, and production measurement showed Pad Thai Padel at 1,821 rows and PTP Bali at 1,008 in the old 30-day window — the headline Revenue tile was silently summing roughly the first thousand of 1,821 bookings; and utilization divided by a full open day on any period ending today, so "Month to date" at 03:00 reported a collapse that had not happened. See DEC-452 (the control, the default, which figures follow the period) and DEC-453 (the truncation, the unstable page ordering, the single lesson-revenue pass).
components/admin/reports-dashboard.tsx — the house DateRangePicker (pastOnly, Month to date, no allowAllTime, testIdPrefix="dashboard"), a Clear that resets to the default, a scope note stating that every figure follows the period plus the Revenue/Utilization definitions and a partial-period notice, chart titles that name the bucket the server chose, tabular-nums columns, an overflow-x-auto court table for phone width, and theme tokens only so light and dark both hold.
🔴 A failed read no longer renders as ฿0. The old component caught its fetch error, commented "fall through to show empty state" and painted every tile with `?? 0` — a broken query produced a dashboard reading ฿0 and 0 bookings, indistinguishable from a club that took no money. Tiles are nullable and render "—", the error is shown inline with its reason plus a Retry and as a toast, and a stale out-of-order response is discarded by a request sequence stamp.
lib/dashboard-range.ts (new) + 38 node:test cases — the pure period arithmetic: bucket selection on rangeGranularity's own thresholds (≤1 day hour, ≤60 day, ≤420 week, else month), bucket key/series/axis-label helpers seeded from the PERIOD so an empty day is a zero rather than a gap, minuteOfDay returning NaN (never 0) for a malformed schedule row, openMinutesOnDay/openMinutesInWindow with the partial-day clamp on the utilization DENOMINATOR, elapsedBookedMinutes with the matching clamp on the NUMERATOR (an unclamped numerator over a clamped denominator is the 309% bug from the other side — 90 booked minutes against 30 elapsed, masked at 100%), utilizationPct returning null rather than 0% when no opening hours have elapsed, and validateRangeParams — the 400 gate that refuses a missing, malformed, non-calendar (2026-02-30) or inverted range instead of widening or emptying it.
lib/court-open-hours.ts — new availableMinutesForRange() walking facility-local DATE STRINGS (shiftDateStr/dayOfWeekStr, no instant arithmetic to drift under DST) with the final day clamped to the current facility-local minute; shared fetchScheduleMap() so "what counts as open" has one implementation; computeAvailableMinutes()'s rolling-window contract unchanged for the occupancy badge.
api/admin/reports/route.ts — date_from/date_to REQUIRED and validated before any query (400 with a message naming the parameter); window resolved through facilityDateRangeBoundaries (its own try/catch degrades an unusable IANA id to UTC bounds instead of a 500); upper bound min(end of date_to, now) so the utilization numerator and its clamped denominator describe the same question; scoped in the DATABASE before any count; reads PAGED TO COMPLETENESS in 1,000-row batches ordered by the UNIQUE id column with an explicit refusal at the ceiling; lesson revenue resolved ONCE for the window (was twice, from two query sets — how the tile and the chart came to disagree); retired-court activity measured and reported so the table cannot silently sum to less than the tiles; no new (supabase as any) — widened to SupabaseClient's untyped default generic.
🔴 Coaching revenue credited ONCE per lesson (DEC-454). Unifying the tile and the chart onto one lesson-revenue map inherited the chart's per-BOOKING keying, which double-counts a lesson with two is_primary lesson_courts rows — and because the tile, chart and court table all read that map, all three would be wrong together, removing the disagreement that was previously the only signal. The rule moved into a pure, unit-tested lessonRevenueByBooking() that credits the first primary (stably ordered) and returns duplicates/priceless lessons as diagnostics. lesson_players is now filtered to non-cancelled seats (in JS, because PostgREST .neq also drops NULLs and a NULL-status seat is a real paid seat). Measured on prod: 5,160 lesson_courts rows, 5,159 primary, 0 duplicates — latent, not live.
supabase/migrations/20260930204812_lesson_courts_one_primary_per_lesson.sql — the partial unique index (lesson_id) WHERE is_primary that migration 20260517072915 has asserted in a comment since May but never enforced. 🔴 WRITTEN, NOT APPLIED: additive and reversible, verified to build against prod today (0 violations), plain CREATE INDEX not CONCURRENTLY because db query wraps the file in a transaction (error 25001). Applying is Alba's after sign-off.
Review hardening: the one unguarded timezone call (utcToFacilityLocal in the series loop, which throws RangeError on an invalid IANA id and would have 500'd the handler the route's own comment promised it would not) replaced with safeFacilityLocal, which also returns the hour so it subsumes the facilityHour call beside it; court_schedules and courts reads paged like the rest (a truncated schedule read SHRINKS the utilization denominator and gets masked at 100% — a wrong number that looks like a perfect one); the three silent lesson zeros separated and logged; the fetch error path given the same stale-response guard as the success path.
e2e tc-033 updated to the new contract: assertions moved off Tailwind class selectors onto data-testids, the count case drives the picker to Last 7 days (Month to date on the 1st legitimately excludes the seed's yesterday bookings), chart titles and the series/tiles reconciliation follow the new response shape, the preset helper waits for the mount-time fetch before registering its own (the <h1> is server-rendered and passes before the client's first request resolves), and four new cases cover the 400 gate across seven malformed inputs, the Month-to-date default plus the scope note, that the money screen cannot reach All time, and 33p — coaching revenue counted exactly once in the tile, its series bucket and its court's row. 🔴 The seed previously left lesson_courts.is_primary at its false default, so coaching revenue was 0 by construction and the entire lesson-revenue branch was a NO-OP under test; it now seeds a primary row and two players. NOT RUN — the browser extension was unavailable and Playwright needs a dev server against the shared local Supabase.
v0.53.1Shipped
Unified date range picker — one Polaris control for every admin date filter
28 Sep
apps/admin had four date-filter designs at once: bare <input type="date"> pairs with no default and no presets on Billing, Sessions, Lessons and Bookings, a seeded pair on the Operations log, and (on an unmerged sibling branch) a segmented pill with its own vocabulary. Nestor: "stop doing new and different designs all over the place" — and CLAUDE.md names the house pattern: Shopify Polaris. This ships the one Polaris date range picker — trigger naming the preset and the resolved dates at all times, popover list of presets, Custom last with its inputs inside the popover — applied to every date filter in the admin, the old inputs deleted. Vocabulary: Today · Yesterday · This week · Last week · Month to date · Last month · Last 7 days · Last 30 days · Custom. See DEC-430 (the control), DEC-431 (per-screen defaults and which tiles follow the period), DEC-432 (what was kept from the sibling branch and how the two merge).
lib/date-range.ts — the pure resolver: preset → inclusive facility-local YYYY-MM-DD pair, read in the FACILITY's timezone (rangeToday with an Intl fallback), pastOnly clamping both ends, custom ranges ordered-not-trusted with an incomplete pair falling back to the screen's default, long and short range formatters via anchoredDateFormat. Lineage: the sibling branch's academy-period resolver, kept in behaviour, with the Polaris vocabulary.
components/admin/date-range-picker.tsx — the Polaris-structured, Linear-finished control: h-9 trigger with calendar icon, preset label, resolved dates and chevron; popover listbox (role=option, aria-selected, Up/Down/Home/End roving, Enter/Space, Escape) in two groups with a per-option date preview; Custom expands From/To + Apply inside the popover; theme tokens only so light and dark both hold; cursor-pointer everywhere; fits a phone (min(100vw − 2rem, 20rem)). Plus useDateRange(timezone, { defaultPreset, pastOnly }) returning range/setRange/reset/isDefault.
Applied to Members → Billing (Month to date; Collected scoped to the period and printing the dates, Outstanding/Overdue/Renewals labelled all-time with a scope note), Members → Sessions (Month to date, past-only), Academy → Lessons (This week, forward-looking), Bookings (All time — the one named exception, a lookup screen; opt-in allowAllTime), Operations → Log (Last 7 days, past-only, against the facility BUSINESS date; digest ?from=&to= links land as Custom). Raw date inputs removed from all five; "narrow the date range" copy reworded.
lib/date-range.test.ts — 32 node:test cases: the sibling's 24 ported case for case (month ends, leap February, December rolling back a year, Sunday-start weeks, the facility-timezone case against a fixed instant, past-only never resolving forward) plus Yesterday, Last week across month/year boundaries, Month to date on the 1st, the rolling windows, All time, the screen-default fallback, and the short label.
e2e tc-286 and tc-288 updated to drive the Operations log through the picker's Custom path (log-date-range → preset-custom → custom-from/to → custom-apply). Not run in this branch — the browser tooling was unavailable to the authoring session.
Follow-ups on rebase (DEC-432): feature/academy-period-filter-and-paid-gating drops its pill + resolver and swaps Enrollments to DateRangePicker; feature/academy-collections swaps its due_from/due_to inputs the same way.
0
v0.52.14Shipped
Group lesson check-in — reopening a ticket no longer re-shows the class price
29 Sep
Reported live by Alba, recurring for ~1 month: a package-funded group-coaching student checked in at ฿0 due (correct — the package already paid for it), but by the time staff closed the ticket the checkout screen showed the full class price as still owed, as if the student needed to pay again. Root cause: terminal-store.ts's loadCartFromTicket() unconditionally re-derived a reopened ticket's isPrePaid/isUnpaid from the lesson's ONE shared bookings row — meaningless for a group class, where each student's real payment status is resolved per-student at check-in and already correctly persisted. See DEC-447.
loadCartFromTicket() only re-derives isPrePaid/isUnpaid from bookingInfo for a court booking now — a lesson student's already-resolved settlement (package/stripe/credits/external) survives a ticket reopen untouched
Traced the actual money path, not just the display: use-order-mutations.ts's amountDueCents (what's actually written to pos_payments) was already computed from the line's settlement value directly, never from the corrupted isPrePaid — so no double-charge ever reached the ledger, but a spurious ฿0 real-tender row was still created alongside the settlement row, and staff were shown a live 'still owes' prompt for an already-settled student
New e2e regression (TC-294): checks in a package-funded group-lesson student, reopens their ticket via the terminal Tickets list (the exact code path that corrupted the flags), asserts checkout still shows ฿0 (not the class price), completes the charge, and confirms pos_payments contains only the automatic package settlement — no real cash/card row. Confirmed the test is a real regression check by reverting the fix and reproducing the exact reported symptom (Charge · ฿800.00) before restoring it
1 E2E tests
v0.52.13Shipped
POS reports — filter by item or category, drill from an item to its days and bills (REP-05)
28 Sep
Operator feedback (POS Punch List, REP-05): Item sales and Sales breakdown showed everything at once, with no way to look at one item or one category, and clicking an item did nothing — so 'when and to whom did we sell X?' had no answer. Both reports now filter by item(s) and by category or group, and an item opens a drill-down with its sales day by day and every bill it was on. Same data basis as before (completed orders, order date, location-scoped). DEC-446.
Item and Category filters on Item sales and Sales breakdown — searchable multi-select; groups (root categories) tick as a whole and match everything under them; AND between the two filters, OR within each
Item drill-down (/reports/item-sales/detail): KPIs, day-by-day table (click a day to narrow the bills), and the bills with date/time, bill number, customer, staff, qty and amount
A bill opens in the same OrderDetailModal Transactions uses, with prev/next through the item's bills — /orders/[id] is not linked because it has been crashing since 05fa01a3 (flagged in the PR)
Range, filters and group-by live in the URL, so Back and the drill-down's back link restore the exact view, and a filtered report is shareable
Items keyed by catalogue variation, not by name — a renamed item stays one row, two items sharing a name no longer merge; Custom Amount lines keep a name key
Sales breakdown now excludes comp'd (is_foc) lines like Item sales and the email reports — the two screens agree on gross for the same range
Item sales hourly chart uses real per-item hours (it used to spread the day's total across the top 5 by share); GTIN/SKU columns (always '—') replaced by Category and Bills
Load failures render an error instead of an empty 'no sales' table
v0.52.12Shipped
Academy — recurring plans actually bill
18 Sep
🔴 THE BILLING MACHINE HAS BEEN RUNNING CORRECTLY, HOURLY, OVER AN EMPTY SET. Nestor asked 'how are recurrent payments created?' and the answer is that they are not. cron job dispatch-package-billing is active with 362 successful runs since 24 Aug, and every one of them selected zero rows: it requires player_packages.billing_mode = 'automatic' AND next_billing_date IS NOT NULL, and — measured read-only on production — ALL 207 player_packages are 'manual' and ZERO rows of any status have ever had a next_billing_date or a billing_anchor_day. payment_requests has ZERO rows. 14 active enrolments sit on a template that declares a billing_period, so they genuinely recur, and no renewal invoice has ever been raised for any of them. Nothing in the enrolment path wrote either column. This wires enrolment into the cycle, adds the 7-day advance-invoicing lead Nestor asked for, and fixes two money defects that were dormant only because no request had ever existed. THE BACKFILL THAT SWITCHES THE 14 LIVE PACKAGES ON IS WRITTEN BUT DELIBERATELY NOT APPLIED.
ENROLMENT ENTERS THE CYCLE, AND IT IS THE TABLE THAT DOES IT (DEC-411). A BEFORE INSERT trigger on player_packages seeds next_billing_date and billing_anchor_day and copies the template's billing_mode whenever lesson_packages.billing_period is set. player_packages has FIVE live writers — the admin assign route, assign_package_with_credits, the v1 confirm-purchase route and both Stripe webhook inserts — so fixing one route would have left the same bug in four. Recurrence is decided by billing_period and never by package_type: production's 'Growth' template is package_type 'session_bank' with billing_period 'monthly' and carries 6 of the 14. The first seeded date is the first RENEWAL, not today, because the counter sale already collected the first period. One-off credit packs (billing_period NULL) and Stripe-backed packages are untouched.
THE DEBT APPEARS 7 DAYS EARLY, AND IT DOES NOT READ AS OVERDUE (DEC-410). dispatch_package_billing() selects cycles up to 7 days ahead and process-package-billing applies the same lead per row, so a period is invoiced a week before it starts. The due_date is UNMOVED — still period_start + default_due_days — because feed.ts:isOverdue is `due_date < facilityToday`, so dating the request early would render every recurring plan permanently Overdue from the moment it appeared. It reads Due for about 14 days and flips to Overdue only once the cycle date plus grace has genuinely passed in the club's timezone. Idempotency is untouched: the hourly re-fire is still made safe by payment_requests_subject_period_key, and a wider window changes WHEN a period is first invoiced, never how many requests it can have.
assign_package_with_credits STOPS TRUSTING ITS CALLER (DEC-412). It never read lesson_packages: the charge came from its p_price_amount parameter, the row was inserted with the caller's p_facility_id unchecked, and session banks were filled from any club's package_items. It now reads the template SCOPED TO p_facility_id — which is the tenancy check — charges the template's price, and treats p_price_amount as an assertion that must match or the call fails. Latent rather than exploited (proacl grants EXECUTE to postgres and service_role only), but this change routes recurring billing through it, so leaving it would have been shipping a known money bug into a newly live path. Signature unchanged; no caller breaks.
'MARK PAID' ON A PACKAGE SETTLES THE OLDEST OPEN DEBT (DEC-414). The lookup was a ternary on current_period_start — NULL on all 207 production packages, so only a period-less request could ever be found, and an advance-raised cycle names the NEXT period so it could never match even once that column was populated. It now takes the period-less request first and otherwise the earliest period, with the choice unit-tested. The silent success is closed in the same pass: when nothing matched and the package was already 'paid', the route returned a bare 200 the client toasts as 'Marked as paid' while no money table moved — 197 of 207 packages are already 'paid', so that was the common path. It now returns a warning telling staff not to collect again.
THE BACKFILL IS WRITTEN AND NOT RUN (DEC-413). Measured on production: 14 rows, all pad-thai-padel, Asia/Bangkok, THB, all monthly — 8 x 'Essential' at ฿4,500 and 6 x 'Growth' at ฿9,500, ฿93,000 per cycle. ZERO payment requests would exist after the first hourly run; the first invoice appears 19 Sep (one student, ฿9,500, cycle 26 Sep, due 3 Oct) and the rest follow one or two at a time through 9 Oct. NO BACK-DEBT: the first cycle is the first anchor date strictly after today, so the months the club silently skipped are not raised. NO CUSTOMER IS EMAILED: academy_payment_settings has no row for any facility, so reminders fall back to disabled and none are queued. Nothing is charged to a card and no sessions are granted (DEC-325). The migration prints every row it touches and is idempotent — a second run selects nothing.
FLAGGED, NOT FIXED (DEC-415): the package template form submits `billing_period: packageType === 'recurring' ? billingPeriod : null`, so opening and saving the 'Growth' template without changing anything would NULL its billing_period and de-recur 6 live enrolments — and the same gate means its billing_mode can never be set to 'automatic' through the UI. Deciding whether package_type or billing_period is the authority for 'this recurs' reaches 43 templates, the Payments plan/pack classifier, the dispatcher's join and the subscriptions mirror, so it is checkpointed rather than folded into a billing change.
Tests: 12 new node:test cases for the settlement rule (player-packages/_lib/settlement.test.ts) — 79 unit tests pass across the admin app, up from 67. 24 Deno tests for the billing-cycle arithmetic (process-package-billing/billing-cycle.test.ts), a file that module's header has named since 24 Aug and which had never been written — covering the advance-invoicing window, the non-drifting anchor clamp (31 Jan → 28 Feb → 31 Mar) and the facility-calendar rule. Both migrations were applied TWICE inside a rolled-back local transaction with 12 + 6 in-database assertions, and the local schema was confirmed untouched afterwards.
0
v0.52.11Shipped
Operations P7 — daily Slack digest per facility
24 Sep
The last phase of the Operations hub: a once-daily Slack digest per facility, covering the PREVIOUS business_date's shifts (roster, checklist/tracker completion), customer reports, cancellations/no-shows, and drawer reconciliation — all using P8's real-person attribution, never the shared-login session identity. Runs on a configurable facility-local schedule (default 06:00), not a drawer-close or shift-close trigger — P4c already proved this club's drawer sessions stay open 8-56 days, and shifts have no single facility-wide close event either. See DEC-416 for the full trigger-design debate.
Migration 20260924105110_operations_p7_daily_slack_report.sql — ops_report_settings (facility_id PK, enabled, send_hour_local, owner/admin/manager read+write RLS); operations_set_slack_webhook/operations_get_slack_webhook (Supabase Vault, service-role only, deterministic ops_slack_webhook_<facility_id> secret name, same shape as facility_integrations' set/get_facility_integration_secret); ops_daily_reports (facility_id+report_date UNIQUE, pending->sent|failed, attempts/last_error, no client write policy); operations_display_name() (SQL port of resolveDisplayNames' full-name->email->id-prefix->'Unknown (shared login)' fallback chain); operations_compile_daily_report(facility_id, date) — the single source of truth for the digest's contents, STABLE SECURITY DEFINER; operations_dispatch_daily_ops_reports() — hourly pg_cron dispatcher, claim-then-send + 15min/3-attempt retry sweep (same shape as P8's dispatch_payment_reminders).
supabase/functions/send-daily-ops-report/index.ts — Slack Block Kit digest (header, per-shift roster/checklist/tracker section, customer reports, cancellations/no-shows, drawer reconciliation, flags, 'View in Setpoint' action button linking the Log page's ?from=&to= range). Gated by requireMachineCaller (x-internal-secret for the cron path, service-role Bearer for the admin 'Send test now' path); updates ops_daily_reports to sent/failed except for a test send (test:true skips that bookkeeping entirely so a manual test never counts as the real daily send).
Admin: new 'Daily Operations Report' card on Operations Setup (enabled toggle, hour-of-day select rendered as e.g. '6:00 AM', masked webhook field with last-6-chars display, 'Send test report now' button compiling TODAY's report via the edge function's service-role-Bearer path). New API routes /api/admin/operations/report-settings (GET/PATCH) and /api/admin/operations/report-test (POST).
Operations Log page: per-date badge ('Report sent HH:MM ✓' / 'Report failed' / nothing) reading ops_daily_reports for the visible range (log/route.ts extended to return daily_reports alongside its existing shifts/checklist/tracker/report payload — one aggregate read, matching that route's existing design). Log page now also accepts ?from=&to= to seed its date range, so the digest's 'View in Setpoint' link actually lands on the report's own date.
DEC-416 (schedule-vs-event-trigger decision, full self-debate against drawer-close/shift-close/manual-button/hourly-digest alternatives) logged.
4 E2E tests
v0.52.10Shipped
Operations P8 — real-person attribution via a persistent "Acting as" chip
24 Sep
Confirmed, repeatedly-reported bug: the reception iPad/laptop login is SHARED across all staff, but checklist completions, tracker counts, payment verifications, and booking cancel/no-show writes were all attributed from the authenticated session's own user id — in prod, all 9 checklist completions to date said 'Alba Garrido' regardless of who actually worked the shift. Generalizes P6's ops_shift_staff roster and P4c's per-action counted_by_user_id picker into a single persistent, device-level 'Acting as' selector — not a picker repeated on every tap (16+/day for checklist/tracker alone at this club) — pinned at the top of admin's Operations Today page and in terminal's header, feeding a REAL-person id into every Operations write. See DEC-415 for the full design rationale and rejected alternatives.
Migration: completed_by_user_id/uncompleted_by_user_id on ops_checklist_completions, counted_by_user_id on ops_tracker_counts, verified_by_user_id on pos_payment_verifications, staff_user_id on booking_status_events, followed_up_by_user_id on ops_customer_reports — all additive, existing session-identity columns kept as audit metadata. No backfill; historical rows render 'Unknown (shared login)'.
Two shared SECURITY DEFINER helpers: operations_assert_acting_user (active ops_shift_staff membership for that facility+business_date — BLOCKING, admin checklist/tracker/customer-report writes) and pos_assert_facility_staff (any user_facility_roles membership, no roster restriction — terminal payment verification + both apps' booking-outcome; factored out of record_drawer_count_lines's existing inline check, which now calls the shared helper instead of duplicating it). operations_toggle_checklist_completion, operations_upsert_tracker_counts, verify_pos_payment, unverify_pos_payment, bulk_verify_pos_payments_window/bulk_verify_pos_payments, set_booking_outcome (+its lesson-leg cascade helper) all extended (DROP + CREATE per this repo's PostgREST-overload-hazard convention).
Admin: acting-as-chip.tsx (dropdown of today's on-shift roster union across every shift slot, + 'Add someone…' into the existing P6 flow) wired into the Today page; checklist/tracker writes blocked with an inline 'pick who you are first' prompt when unset; getOrCreateShift seats the acting-as person (not the session) onto a newly-opened shift's roster. Log page shows the real roster per shift instead of 'Opened by <session>', and real attribution (with 'Unknown (shared login)' fallback) on every checklist/tracker/report row.
Customer Reports rework: two explicit modes (Existing customer — typeahead must resolve to a pick, shown as a linked chip; Not a customer/walk-in — a single optional Name field). The 'Contact' field and the old per-form 'Who's logging this?' dropdown are removed (the chip replaces the latter). New 'Mark followed up / Close' action on Log rows (ops_customer_reports.status was permanently stuck at 'open' until now).
Terminal: the same acting-as chip in the header (sourced from the facility team roster, matching P4c's own picker scope); payment-verification-modal gains a required 'Verified by' picker defaulting to the chip's pick and persisting changes back to it; the drawer-count modal's existing 'Who counted?' picker now defaults to the chip's pick (stays a deliberate per-action confirm); Transactions' 'Verified by' prefers verified_by_user_id with a fallback to the historical verified_by.
Dead P5-superseded linen API routes (linen-counts, linen-item-types — confirmed unreferenced by any UI) removed; 'linen counts' subtitle text on the three Operations pages updated to 'daily counts'.
2 E2E tests
v0.52.9Shipped
Operations P4c — multi-count drawer reconciliation (recounts, not just close)
23 Sep
P4 (v0.52's reconciliation work) and P4b both assumed a drawer session closes roughly daily — a single close-time snapshot was 'the' reconciliation for that shift. Live prod data proved that wrong: pos_drawer_sessions sessions actually stay open 8-56 days (closing implies a bank trip, which doesn't happen daily). This introduces the 'count' as a first-class primitive — a point-in-time cash+card+QR tally a session can have many of over its open lifetime — alongside a new terminal 'Count Drawer' primary action for routine mid-shift recounts, on top of the existing close flow. A code-review round after the initial build fixed 10 findings, the most serious being the Cash Drawers report silently picking an arbitrary tender-count row for any recounted session instead of aggregating across its count history.
Schema: pos_drawer_counts (one row per count — recount or close, at most one close per session via a partial unique index) and a reshaped pos_drawer_session_tender_counts (now a count's LINE table via count_id, not a session's single snapshot — safe reshape, 0 rows in prod, guarded with a runtime row-count check). Windowed siblings of the existing tender-total/verification-count/bulk-verify RPCs (new names, not overloads) so a count's card/QR figures scope to 'since the previous count', not the whole session; the originals become thin backward-compatible wrappers.
record_drawer_count() — the one write path for both a mid-shift recount and a standalone pre-recorded close-count; end_drawer_session_atomic extended with p_counted_by_user_id/p_ops_shift_id/p_window_end so an actual close also writes its own close-count via the same shared insert logic, guaranteed identical row shapes either way.
Terminal UI: EndDrawerModal generalized into DrawerCountModal (mode: recount | close); the sticky action bar's new PRIMARY button is 'Count Drawer', with 'End session & bank drop' demoted to secondary; a required 'Counted By' picker (any staff member at the facility, reusing the existing Team roster RPC — no new roster-wiring); 'Taken This Shift' renamed 'Since Last Count' (windowed) with a small whole-session 'Session total' subline; a new 'Counts' list on the current-drawer view and a 'Counts' tab on the session-detail view.
Code review round, 10 findings fixed: (1) Cash Drawers report (apps/pos) now SUMS every count's line per bucket instead of picking one row arbitrarily, plus a new expandable per-count breakdown (date/time, who, kind, cash/card/QR expected→counted→diff) on both the on-screen report and made explicit (not silently scoped) for the evidence-count total. (2/3/5) Window authority: the terminal now freezes BOTH window bounds together at modal-open (was: only the end, with a still-live start) and submits that exact window to record_drawer_count/end_drawer_session_atomic/bulk_verify_pos_payments_window, which honor it but clamp server-side to never exceed the real current instant — so what staff saw is what gets recorded, never a payment silently swept into an immutable row unseen. (4) A recount no longer requires cash > 0 (a legitimate ฿0 reading right after a bank drop) — close keeps its original > 0 requirement. (6) A close-count collision (pre-recorded via record_drawer_count, then a real close follows) now raises a named close_count_already_recorded instead of a raw unique_violation. (7) counted_by_user_id is now validated to actually hold a role at the session's facility, not just NOT NULL. (8) counted_by_user_id's FK is now ON DELETE RESTRICT, not SET NULL (the column is NOT NULL, so SET NULL was always going to fail anyway). (9) A close with a counted-by but zero card/QR activity now still writes a cash-only close-count — the earlier version silently skipped it whenever p_tender_counts happened to be empty, breaking the 'every closed session has exactly one close-count' invariant the report relies on.
1 E2E tests
v0.52.8Shipped
Transactions — settlement tenders stop double-counting as new sales (Phase 2a)
22 Sep
Phase 1 (v0.31, PR #1095) fixed a court/lesson booking paid for earlier — via a package, or prepaid at booking — from showing as a ฿0 line in POS with no record of where the money came from: the line is now written at its real value, settled by an automatic pos_payments row (method prepaid|package). Phase 1 never taught anything that claims to show 'collected money' to EXCLUDE those settlement rows — they were counted as brand-new sales everywhere (Collected tile, Avg Sale, transaction count, day subtotal, CSV export), double-counting revenue. A club in Bali saw ฿0 on the ticket, then the full class price again in Transactions after closing it. This phase closes that gap.
A single Postgres source of truth — pos_is_settlement_method(text) — decides 'is this a settlement, not a sale' for every RPC that needs the answer, kept in lockstep with AUTO_SETTLEMENT_KEYS in packages/common/src/payment-methods.ts (a new, deliberately narrower isAutoSettlementMethod() than the pre-existing isSettlementMethod(), which also covers gift_card/credits — real picker-chosen tenders, not auto-applied settlement rows).
pos_transactions_summary now excludes settlement rows from total_collected_cents/avg_sale_cents/txn_count and returns two new fields (total_settled_cents/settled_count) carrying exactly what was excluded — additive only, every existing return column unchanged.
Both apps' Transactions/Sales page: a settlement row stays visible for audit ('show, don't hide') but renders muted with a '· Paid earlier' suffix; day subtotal and the Collected/Avg Sale/Transactions tiles exclude it; a 5th 'Settled' tile surfaces the total. A new 'Paid earlier' filter chip (p_method: 'settled' pseudo-value, understood by both RPCs) isolates settlement rows. The pre-existing 'Settled' status chip (Stripe/gateway payment status) is renamed 'Settled (gateway)' to disambiguate from the new settlement-tender concept.
CSV export gains a 'Settled (Prior Payment)' column right after 'Total Collected'; a settlement row reads 0 in Total Collected/Card/Cash/Gift Card/Other Tender and its value in the new column instead.
Code-review finding fixed before merge: pos_is_settlement_method(NULL) previously returned SQL NULL (three-valued logic on a bare IN), which would have silently dropped any NULL-method payment row from every FILTER clause built on top of it — coalesced to false.
2 E2E tests
v0.52.7Shipped
Lessons list — the live player search stops silently losing people
18 Sep
🔴 A LIVE BUG ON A SHIPPED SCREEN, fixed on its own and separate from the Members work. The Lessons list resolved player names with an unfiltered, unlimited `customers` ilike. Production holds 7,301 customers and PostgREST caps a response at 1,000 rows, and every realistic two-letter search exceeds it — measured: ma 4,406, an 3,473, on 2,300, le 1,591, ar 1,475, ch 1,151. Staff typing the first two letters of a name got 1,000 ids drawn from EVERY club, the player they were looking for need not have been among them, and nothing on screen said the list was partial — so it read as 'this player has no lessons'. The people are now resolved from this facility's own player population, completely, and the coaches read (also globally unscoped) is facility-scoped through academy_facilities → coaches.academy_id.
THE PEOPLE ARE SCOPED AND COMPLETE. Candidates are the distinct `lesson_players.user_id` behind this facility's lessons — paged to completeness, because the busiest club holds 4,894 of those rows against the same 1,000-row ceiling that caused the bug, so a single read would have reproduced it. Names are then read for that bounded set in chunks and matched in memory. The scan has a hard page ceiling; reaching it is reported as an error, never absorbed, because an incomplete candidate set is exactly how a match goes missing.
THE LEDGER'S SHAPE COULD NOT BE COPIED, AND THE REASON IS A FOREIGN KEY. `lesson_players.user_id` and `coaches.user_id` reference `auth.users`, NOT `public.customers` — verified against production's pg_constraint — so PostgREST cannot embed a customer's name from either table and the match cannot be pushed into the database. Checked before designing, rather than discovered after.
A SEARCH TOO BROAD TO EXPRESS IS REFUSED, NOT TRIMMED. Scoping the people correctly makes the lesson id list BIGGER, because it stops dropping matches: a correct `ma` at pad-thai-padel names 2,894 distinct lessons, a ~107 KB `id.in.(…)` URL, about three times the largest list this route already sends. Rather than cap it silently — the original defect, one layer down, under a `total` that was never filtered — the route answers 400 with the one instruction that shrinks it: type a few more letters, or add a date range. Three letters or more is well inside the envelope (cha 405, nat 123, mar 108, ann 33). The client now shows that message instead of discarding it behind a bare 'Failed to load lessons'.
THE STRUCTURAL FIX IS NAMED AND NOT ATTEMPTED HERE (DEC-403). A database-side semi-join — an RPC, or a real foreign key from lesson_players.user_id to customers so the filter becomes embeddable — is what removes the refusal entirely. It is a migration against a live database and needs a deploy to have any effect, so it belongs in its own reviewed change rather than inside a search-block bug fix. Two caveats are recorded rather than asserted: production's PostgREST max_rows is assumed to be config.toml's 1000 and could not be confirmed from SQL (no pgrst.db_max_rows in any role's rolconfig), and the ~37 KB URL being accepted today is inferred from the live symptom being a wrong list rather than a failed request. Neither affects the truncation arithmetic.
Unit tests (node:test): lessons/_lib/search.test.ts pins that a blank term never matches everyone, that 'first last' matches a name split across two columns, that chunk() never drops, duplicates or reorders an id (an off-by-one there loses players from a search), and that the bounds are the measured ones. 67 unit tests pass across the admin app.
0
v0.52.6Shipped
Academy Members — Payments is package money, and only package money
18 Sep
Nestor: 'there is a massive issue in payments under members, this should be only for packages not for people who joins lessons sporadically.' Measured read-only on production before anything changed: the feed carried 4,885 drop-in lesson rows at pad-thai-padel against 27 package sales — the packages screen was 99.4% walk-ins, and the 27 packages an operator came to find could not be reached by scrolling. The lesson_players source is gone in both its halves, and memberships with it. The feed is now exactly three package sources: payments.type='package', player_packages not yet paid, and payment_requests on a package. 🔴 THE TILES MOVED WITH IT — narrowing the feed alone would have shown Collected ฿2,692,800 above 27 rows worth ฿216,000. Drop-in money is not deleted as a product: Nestor asked for it back as a Revenue tab sourced from POS, which is deliberately NOT built here.
BOTH HALVES OF lesson_players ARE REMOVED, FOR TWO DIFFERENT REASONS. The 5,078 DROP-IN rows (player_package_id IS NULL, ฿2,476,800) are point-of-sale takings — somebody who pays for one lesson at the counter has not bought a package. The 410 PACKAGE-FUNDED rows (player_package_id NOT NULL) total ฿0 by construction, because a package seat deducts a credit instead of taking money; they are ATTENDANCE, and attendance is what the Sessions tab shipped in v0.52.5 is for. Neither is added to Sessions here.
🔴 THE SUMMARY TILES WERE NARROWED IN THE SAME CHANGE, WHICH IS THE HALF THAT IS EASY TO MISS. Collected is computed by academy_payment_summary() in SQL, not by the route, so the feed could have been narrowed while the tile kept summing lesson_players.paid_amount. Migration 20260917183320 moves three predicates and nothing else: the collected_lessons CTE is deleted, collected_payments narrows to type='package', open_requests gains subject_type='player_package'. Before/after at pad-thai-padel: Collected ฿2,692,800 → ฿216,000; feed rows 4,912 → 27. At pad-thai-padel-bali (IDR): Collected Rp 115,460,000 → UNCHANGED, feed rows 365 → 11, because every one of its 354 drop-in rows was worth zero. DEPLOY ORDERING: the migration must land in the same window as the app, or the two disagree again.
MEMBERSHIPS ARE OUT OF SCOPE, AND THE COST WAS MEASURED BEFORE THE CALL. A membership carries no lesson_packages template, no billing_period and no session balance — it was only ever filed under 'Recurrent Plans' because it recurs. Verified read-only: of the 8 type='membership' payments on production, ZERO belong to either live club; all 8 sit on five bug019-smoke-* facilities at USD 999.00 each. The removal costs no real row at pad-thai-padel or pad-thai-padel-bali. The database vocabulary is untouched — payment_requests_subject_type_check still admits 'membership'.
REVERSALS STILL NET OUT, VERIFIED AT THE SOURCE RATHER THAN ASSUMED. revert_academy_payment copies the ORIGINAL row's type onto its negative counter-row, so a reversal of a package sale is itself type='package' and survives the narrowed Collected predicate alongside the sale it cancels. The feed's two-directional reversal hiding is untouched: both the negative counter-row and the original sale row stay hidden, because a reverted package returns through the unpaid-packages source and showing the original too would list the same package as 'Paid' and 'Pending' at once.
'SUBJECT IS A PACKAGE' IS EXPRESSED AGAINST THE REAL VOCABULARY, NOT A GUESSED ONE. payment_requests.subject_type carries a CHECK constraint admitting exactly 'player_package', 'lesson' and 'membership' — read off live production, not inferred from the migration that created it — so .eq('subject_type','player_package') drops precisely the two known non-package kinds. Worth recording: payment_requests has ZERO rows on production, so every request-shaped path on this screen (the Due state, reminders, mark-paid, settlement-method resolution) is live code with no live data behind it yet.
EVERY FILTER STILL RUNS SERVER-SIDE BEFORE THE COUNT AND BEFORE THE PAGE SLICE, unchanged and re-verified: `filtered` accumulates search, status, method, channel and product, then rows:filtered.slice(...) and total:filtered.length read the same array. The product filter (All / Recurrent Plans / Credit Packs), the payment-state switch (Due / Paid / All), the method and channel dropdowns, and the redirects from /academy/payments and /academy/subscriptions to /academy/members all survive untouched.
THE REMOVAL IS ENFORCED BY THE TYPE SYSTEM, NOT JUST BY A QUERY FILTER. FeedSubjectType is now 'player_package' alone and the source union drops 'lesson' and 'membership', so reintroducing either fails to compile rather than quietly reappearing. It paid for itself immediately: the narrowed union caught a membership branch still live in the payments assembly loop that reading the diff had missed.
🔴 DATA FINDING, INVESTIGATED AND REPORTED, NOTHING WRITTEN: the 44 type='package' payments sum to 11,568,900,000 minor units, which read as baht is an implausible ฿115,689,000. It is NOT a defect and the Collected tile was NOT wrong. It is a cross-currency sum that only exists once the facility filter is dropped — pad-thai-padel (THB) ฿216,000, pad-thai-padel-bali (IDR) Rp 115,460,000, the rest sandbox and smoke-test. Every row matches its own template price exactly and no row disagrees with its facility's currency. academy_payment_summary() filters by facility and each facility has one currency, so it never mixes them. The trap is named for whoever builds the Revenue tab: across facilities, SUM(amount) is meaningless without grouping by currency.
Verification: apps/docs typecheck exits 0; apps/admin typecheck is byte-identical to the 28 pre-existing errors across 11 files on the base branch (stale database.types.ts, prod ahead — not regenerated), and touches none of those files; 50 unit tests pass; eslint clean on every changed file. NOT VISUALLY VERIFIED — browser tooling was blocked by a safety classifier for the whole session, so no screenshot of the narrowed screen exists. No E2E was added or changed: exhaustive greps found no existing e2e case that asserts anything on this screen, so nothing asserted drop-in rows and nothing broke.
0
v0.52.5Shipped
Academy Members — Sessions, the ledger of deductions already taken
18 Sep
Nestor: 'in members please add Sessions and that will be a ledger where we track each player session, the session itself, the time, the day, the coach, type of session group/private and how many left for each type add filters as well this will be a ledger for us to make sure packages deductions are made properly' — then the scope call that defines it: 'sessions under members view is past ones to see deductions we made on packages'. A third tab on Academy → Members, presented like the Lessons list and answering a different question: for every session that has ALREADY happened, which package paid for it, how many were left afterwards, and — the point of the screen — whether a deduction was taken that should not have been. 🔴 THE OBVIOUS READING OF 'PAST' WOULD HAVE SHIPPED A BLANK SCREEN. Filtering on lessons.status is the natural instinct and it is wrong here: read-only against production, ZERO of the 399 consume rows sit on a lesson marked 'completed', while 343 still read 'scheduled' — 223 of them already in the past. Past-ness comes from lessons.start_time <= now(), which also costs nothing, since lesson_id is non-null on all 399 rows. Census: 399 deductions = 249 past + 150 future; 256 rows in scope (249 deductions + 7 corrections) across 81 players, earliest 2 Apr 2026.
THE LEDGER IS DEDUCTIONS, PLUS THE CORRECTIONS THAT REVERSE ONE. In scope: reason='consume' on a lesson that has already happened, and positive-delta 'refund'/'admin_adjust' rows against those same lessons — so a session taken and then given back can never read as a clean deduction. Out: purchase_grant and backfill_balance, which are grants rather than deductions, and everything upcoming. 'Left After' is package_session_transactions.balance_after read straight off the row and NEVER recomputed — recomputing it would replace the club's own record with this endpoint's opinion of it.
🔴 IT FLAGS DEDUCTIONS THE CLUB TOOK AND NEVER GAVE BACK — the reason the screen exists. A consume sitting on a CANCELLED lesson whose session was never returned is money kept by mistake. Inside this screen's own scope, verified read-only: 26 such deductions, 7 of which were properly returned; of the remaining 19, 15 have no positive delta anywhere on that package (11 players) and are flagged 'Not returned' in red, while 4 have some later positive delta that may or may not be the give-back and are flagged 'Check refund' in amber. The two are kept visibly separate rather than merged: claiming a give-back we cannot prove hides a real loss, and claiming a fault we cannot prove sends an operator chasing a refund that already happened. Read-only throughout — nothing is written, fixed or reconciled, only reported.
THE PAST-SCOPED CENSUS IS NOT THE WIDER ONE, AND THAT MATTERS. Across ALL cancelled lessons the figures are 56 deductions / 33 unambiguous / 19 players — but 30 of those 56 sit on lessons that have not happened yet and can never appear on this screen. Quoting them here would have described rows the operator cannot see, so the flag was re-derived inside the screen's own scope before any number was stated.
NEW FACILITY-WIDE ENDPOINT, GET /api/admin/package-transactions, reusing the per-student ledger's security shape verbatim: facility_slug → facility lookup → requireFacilityAccess() → the query filtered by that same facility_id, so no caller can read another club's ledger (this table carries rows for six facilities, so the scoping is real, not decorative). It is a SIBLING of students/[userId]/package-transactions rather than a replacement: that route is live behind the student profile and answers a different question — every reason for ONE student, grants included. It also carries over the untyped-client boundary: package_session_transactions is still absent from the generated types, so the client is widened to SupabaseClient's untyped default generic rather than cast to `any`, which this repo bans for new code.
EVERY FILTER RUNS IN THE DATABASE, BEFORE THE COUNT AND BEFORE THE PAGE SLICE. Search (player or coach), date range, and Private/Group all narrow the query itself, so the '<n> of <total> sessions' line and the rows beneath it always describe the same set — the failure mode being avoided is a filter applied to the page that happens to be loaded, presented as the club's answer under a total that was never filtered. An unrecognised type value is answered with 400, never silently widened to 'all'. The type filter uses a nested classes!inner embed, inner ONLY for a named type so an unresolvable class stays visible under All.
SESSION TYPE READS lessons.class_id DIRECTLY, and the reason is measured. The type is a fact about the class template ('the class is the template that defines it, the lesson is when a session happens'), nominally coalesce(lessons.class_id, transactions.class_id) — but on production both columns are non-null across all 256 in-scope rows (lessons.class_id is NOT NULL), so the coalesce collapses to the authoritative side and the filter becomes expressible in SQL. 🔴 DATA FINDING, REPORTED NOT FIXED: those two columns DISAGREE on 6 of the 256 rows — a ledger row naming a different class than the lesson it points at.
ORDERED BY SESSION DATE, WHICH POSTGREST CANNOT DO. start_time lives on the embedded lesson and PostgREST cannot order parent rows by an embedded column, so the filtered set is read in one bounded window (1000 rows) and ordered here. Ordering cannot change which rows are in the set, only their sequence, so count:'exact' stays exact; if a facility ever exceeds the window the response says truncated and the screen tells the operator, rather than paging a partial set that looks complete. Ties break on descending id, the same direction academy-payments' byCreatedAtDesc uses, so the two sibling tables cannot shuffle ties differently.
PRESENTED AS THE LESSONS LIST, DELIBERATELY. Same filter row (220px search with its Search icon and X clear, the w-[160px] from/to date pair with an en-dash, cursor-pointer Select triggers, a conditional Clear button), same rounded-md border table with an 8-row Skeleton loading state, same infinite-scroll sentinel. Nine columns: Session Date & Time · Session · Type · Coach · Player · Package · Deducted · Left After · Status. Times render in the facility timezone. NO CURRENCY ANYWHERE, deliberately — every quantity on this screen is a count of sessions, not money, so formatting one through formatPrice() would put a THB symbol on a number of lessons.
NO SILENT ERRORS, WITH THE SPLIT MADE ON PURPOSE. A failed ledger read returns 500, never an empty table an operator would read as 'this club has never used a session'. Name lookups are non-fatal and degrade one column to an em-dash (coach resolves on 227 of the 249 past rows), logged rather than swallowed. The give-back lookup behind the flag is FATAL, because an unflagged row reads as a correct deduction and a lookup failure must not be able to certify a wrongly-kept session as clean.
Unit tests (node:test, `npm run test:unit`): ledger.test.ts pins that an unrecognised type is rejected rather than widened, that only a named type makes the class embed inner while the lesson embed is always inner, the integrity flag's four-way split including the two cases that must NOT flag, deterministic tie-breaking, that a missing name is null so the table renders its em-dash, and that a search term cannot break out of the PostgREST or() clause. 50 unit tests pass across the admin app. NO E2E was added: this screen reads production-shaped historical ledger data and the suite needs a live stack that was not available, so an unverified tc-* spec was deliberately not written rather than committed unrun.
🔴 REVIEW PASS — THE SEARCH NEVER WORKED, AND NOW IT DOES (DEC-399). Two separate defects in one block. (a) IT 500'd ON EVERY SEARCH: the coaches lookup filtered `.eq('facility_id', facility.id)` and `coaches` HAS NO facility_id column — proved against production, `select id from public.coaches where facility_id = '…'` returns ERROR 42703 — so PostgREST rejected it, the route's own error branch caught it, and every non-empty term answered 500 'Could not search coaches'. Coaches hang off an academy, reached through academy_facilities → coaches.academy_id, which is how /api/admin/coaches has always scoped them. (b) IT WAS WRONG AT SCALE: the customers lookup had no facility filter and no limit, against 7,301 production customers of which '%an%' matches 3,473 and '%a%' matches 7,257 — past PostgREST's 1,000-row max_rows — so an operator typing 'An' to find Anna got 1,000 arbitrary ids that need not include her, her rows vanished, and the count still read 'N of N sessions'. The rewrite resolves candidates from the LEDGER's own population (this facility's user_ids and lesson_ids in one counted, bounded read), reads only those people's names once and matches in memory via a tested `matchesTerm`, and bounds the coach side by the ledger's 197 distinct lessons instead of the facility's 4,281 — the busiest single coach has 1,211 lessons, past the same 1,000-row ceiling, so that path had both defects too. Any bound actually reached now sets `search_truncated` and the screen says the results may be incomplete. REJECTED, WITH MEASUREMENT: `search_facility_customers` returns ZERO rows under the service-role client this route uses (it is SECURITY DEFINER gated on auth.uid(), which is NULL), and its facility scope omits academy activity entirely.
🔴 REVIEW PASS — ONLY A REFUND OR AN ADJUSTMENT COUNTS AS A GIVE-BACK (DEC-400). The positives query behind the integrity flag was `.gt('delta', 0)` with no reason filter, and `purchase_grant` rows are positive too. A renewal writes a SECOND grant on the same player_package_id — four packages on production already carry more than one — so a cancelled lesson whose session was KEPT flipped from red 'Not returned' to amber 'Check refund' the moment the package renewed, softening the exact loss this screen exists to expose, a month late. Now `.in('reason', ['refund','admin_adjust'])`, with the reason list and a pure `hasLaterReturn()` moved into _lib/ledger.ts so the invariant sits where a test can reach it. The tests were verified to fail before being trusted: putting `purchase_grant` back turns three of them red, including one that walks the production sequence end to end and one that proves a genuine refund is still found among renewal grants.
🔴 REVIEW PASS — PAGINATION STOPS AT THE WINDOW THE SERVER SERVES (DEC-401). The server pages from a bounded 1,000-row window while the client computed `hasMore` from the FULL count, so past the last page in that window the route returned `rows: []`, rows.length stayed below total, the sentinel stayed on screen and the IntersectionObserver re-fired — an endless loop of empty requests against a production route. The route now publishes `window` and the client bounds on `Math.min(total, window)`, rather than hardcoding the ceiling in a second place where it would drift. Latent today (it needs more than 1,000 in-scope rows; the busiest club has 197) and fixed anyway, because the `truncated` banner already discloses the clipping, so stopping hides nothing.
🔴 REVIEW PASS — A BROKEN FACILITY TIMEZONE NO LONGER KILLS THE SCREEN (DEC-402). `facilityDateBoundaries` throws a RangeError on an unusable IANA id and was called unguarded, so one badly configured facility answered the whole ledger with an unhandled 500. Guarded the way the sibling academy-payments feed guards the identical call, with the fallback LOGGED so it stays a fallback and not a silent default. The UTC fallback end-bound is the next midnight, not 23:59:59.999: this route compares with `.lt` where feed.ts uses `.lte`, so copying its string verbatim would have dropped the last millisecond of the selected day while fixing the crash.
0
v0.52.3Shipped
Academy → POS mirror comes back, and this time it pays VAT
17 Sep
Nestor, reversing yesterday's pause deliberately: an academy payment must register itself in the POS again — that automation is the point — and the duplicate sales it exposed are solved by staff no longer keying academy packages into the till, not by switching the automation off. #1114 had paused the forward (money-in) path of record_academy_payment_in_pos_row because two faults were confirmed live the same day: every mirrored order carried tax_total_cents = 0, and the same package was separately rung at the till, so one sale appeared twice. The two are separated here. THE DUPLICATE was never the bridge creating two orders — pos_orders.source_id has carried a partial UNIQUE index since the initial schema — it was a second, independently rung till sale that shares no key with the mirror and that no index can see; that half is process, and the code's own half is now structurally impossible. THE TAX was a real code fault and is fixed: the mirror reproduces the terminal's own calculate-taxes.ts against the facility's own active apply_to_all pos_taxes rows, no rate hard-coded anywhere. Measured read-only against production: all 22 orders the mirror ever created carry zero tax (3–16 Sep), and re-running the recovery backfill today would create exactly ZERO rows — nothing was missed during the pause.
THE UN-PAUSE IS ONE BLOCK, AND ONLY ONE. #1114's `RETURN NULL` (and its comment) sat immediately after the reversal branch; it is gone. Every forward guard that followed it is preserved byte-for-byte: type IN ('package','lesson'), status = 'succeeded', amount > 0, the payment_method IN ('credits','package','pending') exclusion, the no-POS-location WARNING path with its recovery command, and kitchen_sent_qty = quantity so no print station fires. The credits exclusion is called out in the migration because #1114's header corrected its own first draft on exactly that point — credits were never a cause of the duplicate, and un-pausing must not quietly start mirroring them.
THE REVERSAL BRANCH IS UNTOUCHED. It was never paused — it is the only thing that syncs 'Revert to due' back into the POS (pos_refunds row, pos_payments.status='refunded', drawer pay_out) — and nothing in this change goes near it. Re-verified end to end anyway rather than assumed.
TAX IS THE TERMINAL'S OWN RULE, NOT A NEW ONE. apps/terminal/src/lib/calculate-taxes.ts is the single definition of how a Setpoint sale is taxed, called from checkout-dialog.tsx and use-order-mutations.ts with useTaxes({ activeOnly: true }): a tax auto-applies when apply_to_all is true; inclusive = base − base/(1+rate/100); exclusive = base × rate/100; tax_total_cents is their sum. The bridge now reads public.pos_taxes directly and evaluates those same two expressions. One thing must differ and is documented at length: at the till the LIST price is known and the total is derived, whereas here the GROSS is known (p_payment.amount, the money that actually arrived), so the base is SOLVED for — subtotal = gross / (1 + Σ exclusive rates/100) — because pos_payments carries that gross and an order whose total disagreed with its own payment row would break every reconciliation the mirror exists to feed.
THE TAX BREAKDOWN IS WRITTEN TOO, in the shape the till writes it: one pos_order_taxes row per applied tax with order_item_id NULL, identical to create_pos_order_atomic's own '── Taxes ──' block, so /reports/sales-taxes reads an academy order exactly as it reads a till order. The rows are emitted from the same computed lines that produced pos_orders.tax_total_cents, so the breakdown and the total cannot drift apart.
WHAT IS DELIBERATELY NOT TAXED, and why it is not this defect recurring. A catalogue-scoped tax (apply_to_all = false) only ever applies through a taxBaseOverride that resolveCatalogueCharges computes from cart lines' CATALOG ITEMS; an academy line has no item_variation_id and no catalogue, so it cannot qualify at the till either. Production census 17 Sep: Pad Thai Padel - Rama IV 7% inclusive and WareHaus Club 7% inclusive are both apply_to_all, Sunset Padel Club is 7% exclusive apply_to_all — but Pad Thai Padel Bali's only tax is apply_to_all = FALSE, so Bali academy orders legitimately keep tax_total_cents = 0. The fix for Bali is to tick 'apply to all' on that tax, not to invent a rate in a trigger. pos_taxes.apply_to_custom_amounts is likewise NOT honoured: it reads like it should govern a non-catalog line, but repo-wide it is consumed by nothing in the checkout path (only apps/pos's tax-form UI), and honouring it here would make the mirror disagree with the till for the same tax — the exact class of divergence this change closes.
THE DUPLICATE GUARD IS THE INDEX, NOT THE FUNCTION. An existence check in plpgsql is time-of-check-to-time-of-use racy — two concurrent callers (the AFTER INSERT trigger and a hand-run backfill) can both read 'no order yet'. Only a unique index makes a second row physically unstorable. idx_pos_orders_source_id and idx_pos_order_items_source_id already existed; the migration now asserts both IF NOT EXISTS so it OWNS the invariant it claims rather than inheriting it silently. An explicit EXISTS short-circuit is added in front as the fast no-op, so backfill_academy_payments_into_pos() returns without attempting an INSERT-and-swallow-conflict on every already-mirrored row — documented as an optimisation, not the guarantee.
VERIFIED IN ONE ROLLED-BACK LOCAL TRANSACTION against the shared local database, with main's current state (#1114 + #1127) applied inside that same transaction first so the starting point was real, and the new migration applied TWICE to prove idempotency. 22 assertions, all green: inclusive 7% on ฿107.00 → subtotal 10700 / tax 700 / total 10700; exclusive 7% on the same gross → subtotal 10000 / tax 700 / total 10700 (the identity read right to left); apply_to_all = false → zero tax; a second attempt for the same payment returns NULL and creates no second order, line, payment or tax row; a raw duplicate INSERT that bypasses the function entirely is REJECTED by the unique index; credits and pending still create nothing; the reversal still produces refund + refunded status + drawer pay_out; the backfill returns 0; the no-POS-location path still WARNs with its recovery command and leaves the money-truth payments row intact. Rolled back, and the local database was re-checked afterwards at 0 rows and 180 tables.
PRODUCTION STATE, MEASURED READ-ONLY AND WORTH KNOWING: 20260916111842 (#1114's own file) is NOT in production's schema_migrations — the pause reached production only because #1127's rebuild carried the paused body forward. Production's function is paused today regardless (pg_proc confirms it), so this migration lifts it correctly there and does not depend on #1114's file ever having run. 🔴 TWO CLAIMS IN THIS ENTRY WERE WRONG AND ARE CORRECTED HERE (adversarial review, 2026-09-18). It said 'nothing was missed while paused' and 'a backfill re-run would create zero orders': the real backlog is ONE order, a Bali cash package of IDR 12,200,000.00 (payment ac0acf9b-15d8-475e-904e-93cd7ceacd6c, 2026-09-17 10:35 UTC), which the INSERT-time mirror cannot recover on deploy and which needs a deliberate backfill call for that one facility. It also said the 21 'pending' Rama IV payments settle later via a provider webhook: no provider is configured and that row is never written, so those 21 (18,400,000 satang, all on packages already marked paid, zero open payment_requests) are booked as collected in the Academy and will never output VAT — the same VAT-day defect this release fixes elsewhere, and an open product decision rather than a handled case. Both errors shared one cause: the verifying query was built from the same wrong premise as the prose, so it confirmed the belief instead of testing it.
0
v0.52.4Shipped
Academy — assigning reaches Members, Groups is only groups, and the Reports tile stops 404ing
17 Sep
Three things Nestor asked for on the deployed Academy screens. (1) ASSIGNING A PACKAGE BECOMES REACHABLE. 'let's add Assign a package in Members please' — the capability existed but was, in his words, 'super hidden': the only path was Academy → Setup → Packages → Player Packages → Assign Package, four levels down a settings screen, while the screen actually named Members offered no way to enrol anyone. 🔴 THE OBVIOUS FIX WAS A TRAP. assign-subscription-dialog.tsx looked purpose-built for this — it posted the correct body to POST /api/admin/player-packages — but git grep found it had NEVER been rendered by anything, and it disagreed with the live API in three independent places: it read `data.players` from /api/admin/players/search, which returns `{ results }` (so no student could ever be selected); it read `pkg.price`/`pkg.type` from /api/admin/lesson-packages, whose rows come straight off lesson_packages — a table with price_amount and package_type and NO price or type column — so `pkg.price.toFixed(2)` threw the moment packages loaded; and it fetched /api/admin/players/<email>/balance, a route that does not exist (the real one is /api/admin/wallet/customer-balance, keyed by user_id, returning balance_amount). It also hardcoded '$' against a multi-currency product (DEC-108). Mounting it would have shipped a primary action incapable of completing one assignment. The WORKING dialog — the one Player Packages has used all along — was promoted out of player-packages-list.tsx into its own module and mounted on both screens instead, and the broken copy was deleted. (2) GROUPS IS ONLY GROUPS. (3) The Academy overview's Reports tile linked to a route deleted earlier and 404'd on every click.
ONE ASSIGN DIALOG, SHARED BY BOTH SCREENS. AssignPackageDialog moved verbatim out of player-packages-list.tsx into components/admin/assign-package-dialog.tsx and is now imported by that list AND by Academy → Members. Same dialog, same validation, same write path, so an operator assigning from Members gets exactly what Setup gives them — rather than a second assign UI drifting away from the first. The broken, never-rendered assign-subscription-dialog.tsx (573 lines) was deleted; net, the repo shrank.
THE ACTION SITS BESIDE THE TAB BAR, NOT INSIDE A TAB. Tabs left, primary Button right in one `flex items-center justify-between` row — the same header shape the Player Packages list, the group detail page and the coach detail page already use, with the same `size="sm"` + `cursor-pointer` + `<Plus className="mr-2 h-4 w-4"/>` treatment. Screen level is also the honest level: an assignment creates an enrolment AND takes money, so it belongs to neither Payments nor Enrollments alone, and it stays reachable whichever tab is open.
BOTH TABS REFRESH AFTER A SUCCESSFUL ASSIGN, VIA THE EXISTING REMOUNT-KEY MECHANISM. A second counter (enrollmentsRefreshKey) keys the Enrollments list, deliberately NOT a second use of moneyRefreshKey: PlayerSubscriptionsList is what bumps moneyRefreshKey via onChanged after its own mark-paid/extend/cancel, so keying the list on the counter it bumps would remount it on every one of its own actions and silently wipe the operator's search, status and product filters mid-task. The list's own actions refresh Payments; an assign, which comes from outside both tabs, refreshes both.
THE BUTTON IS GATED ON THE ROLE THE SERVER ACTUALLY ENFORCES. owner/admin/manager, mirroring WRITE_ROLES in player-packages/_lib/write-roles.ts — coaches, cashiers, staff and members hold facility roles too and would only ever get a 403. The role is read from /api/admin/facilities/my, the endpoint the sidebar's facility switcher already calls, because it answers at SCREEN level: the established 'route returns access.role in the payload it was already fetching' pattern resolves only while the fetching tab is mounted, and Radix unmounts inactive TabsContent — a button above the tabs cannot be gated on a value that vanishes when you switch tab. Display only; the route re-checks every write. A failed probe fails CLOSED and is logged, never swallowed.
GROUPS IS ONLY GROUPS. Nestor, twice: 'in groups let's only leave the list of groups and remove the rest' and 'remove for once all students, students with packages and leave only groups tag'. The three-tab bar and both roster tabs are gone; /academy/students renders AcademyGroupsPanel directly. students-list.tsx (the tab shell) and students-roster-panel.tsx (300 lines, its only consumer removed) were deleted. The page subtitle, which still read 'Customers with a coaching or academy relationship', now describes the group list. KEPT, and checked with git grep before each removal: /academy/students/[userId] (student detail), /academy/students/groups/[id] (group detail with add/remove), /api/admin/students (the groups panel calls it for unassigned students), lib/package-status and lib/display-name (seven other consumers). Student profiles stay reachable through a group, which is the intended path.
THE REPORTS TILE IS GONE FROM THE ACADEMY OVERVIEW. It linked to ${basePath}/reports, a route deleted earlier — confirmed absent from the tree — so it 404'd on every click. Tile removed and the grid narrowed 5 → 4 columns so the row still fills. A Dashboard replaces it later; nothing was built here to stand in for it.
E2E FOLLOWS THE SCREENS, HONESTLY. TC-262 no longer clicks a Groups tab that does not exist (twice). TC-255/258/259 and TC-261 asserted against the deleted roster (student-row-*, students-search, students-tab-packages) and were REMOVED rather than left skipped, with a header note in each file saying why. TC-256/257 (profile + Customer 360) and TC-261f/g (the /api/admin/students paging contract, still live behind the groups panel, and the Player Packages search) all stay.
🔴 REVIEW PASS — THE ASSIGN ENDPOINT IS NOW FACILITY-SCOPED (DEC-397). POST /api/admin/player-packages read its template with `.eq('id', package_id)` and no facility filter, so an owner/admin at club A could POST club B's package_id and create a real enrolment at club A carrying B's price and B's session banks, plus a `payments` row for B's amount. Pre-existing and API-only — the dialog never lists another club's templates — but this branch promotes Assign to a primary button, and 'the UI cannot send that request' is not an access control. The read now filters on the facility requireFacilityAccess already authorised; a foreign or unknown id returns 404 with no row written, and the two are indistinguishable on purpose. `package_items` could NOT take the same filter: it has no facility_id column, verified against production's information_schema rather than the generated types, so its scoping is transitive through package_id — which the now-scoped template read is what proves. Its swallowed error was also fixed: a failed read left `items` null, skipped the session-bucket insert and returned 201, i.e. a customer who paid for sessions they cannot book; it now rolls the package back and returns 500. REPORTED, NOT FIXED HERE: `assign_package_with_credits` has the same class of hole (it never reads lesson_packages, and takes the price as a parameter) — a live SECURITY DEFINER function whose fix belongs in its own migration. It is unreachable today: this route is its only caller in the whole repo, and it now 404s before the credits branch.
🔴 REVIEW PASS — THE ASSIGN DIALOG SAYS WHEN ITS PACKAGES FAILED TO LOAD (DEC-398). The lesson-packages fetch ended in `.catch(() => setPackages([]))`, so a dead network or a 500 rendered an empty dropdown indistinguishable from a club with no active packages — an operator could reasonably conclude the templates had been deleted. Carried over verbatim in the move, which is exactly when it stopped being harmless. A non-OK response is now thrown rather than parsed as data, and the two empty states are visibly different: a destructive-styled message naming the failure, or a plain 'This club has no active packages to assign.' Reuses this file's existing searchError styling rather than adding a third error treatment to one dialog.
v0.52.2Shipped
Academy Members — the address matches the name, and both tabs cut by product
17 Sep
Nestor, looking at the deployed screen: make the product filter a segmented control like the Due/Paid/All switch and put it to its left, put the same filter on Enrollments, 'and change the url … academy/members'. (1) THE URL MOVES. v0.52.0 relabelled the sidebar entry Members and deliberately left the route at /academy/subscriptions, so the nav and the address bar disagreed; the page now lives at /{facility}/academy/members. Both older paths stay as permanent redirects that carry the query string verbatim — /academy/subscriptions (new) and /academy/payments (retargeted straight at /academy/members rather than bouncing through the intermediate shim). (2) THE PAYMENTS PRODUCT FILTER BECOMES A PILL, immediately left of Due/Paid/All in the same row, built from the same markup tokens. The state, the `product` param and all server-side behaviour are untouched — only the control changed. (3) THE SAME PILL LANDS ON ENROLLMENTS, filtered SERVER-SIDE in /api/admin/player-subscriptions for the same reason the payments feed is: that route pages (DEFAULT_LIMIT 50) and one club holds 206 enrolments, so narrowing the loaded array would filter one page and present it as the club's answer. The classification path was verified rather than assumed — player_subscriptions was copied from player_packages tonight and both carry a package_id — and it is player_subscriptions.package_id → lesson_packages.billing_period: NOT NULL is a Recurrent Plan, NULL is a Credit Pack. Production census, read-only: 206 enrolments = 67 plans + 139 packs + 0 unresolvable, and unresolvable is 0 BY CONSTRUCTION, since package_id is NOT NULL behind player_subscriptions_package_id_fkey REFERENCES lesson_packages(id) ON DELETE RESTRICT.
THE ROUTE MOVE, WITH NOTHING LEFT DANGLING. apps/admin/src/app/[facility]/academy/subscriptions/ → .../members/ (git mv, so the page itself is unchanged). A new redirect stands at the old path, and academy/payments/page.tsx now points at /academy/members DIRECTLY instead of chaining through /academy/subscriptions — a redirect chain is one more hop to break, and the middle path is itself only a shim. Both redirects share one helper, src/lib/forward-search-params.ts, rather than the same loop copy-pasted twice: two redirects meant to behave identically should not be able to drift apart. Repeated keys are re-emitted as repeats, `undefined` (Next's 'not supplied') is dropped while an empty value is kept, and ?view= / ?overdue= / ?open=<payment_request_id> / ?tab=members all survive — ?tab=members is the published deep link onto Enrollments, so dropping it would silently land a colleague back on Payments.
PRODUCT AS A SEGMENTED CONTROL, NOT A DROPDOWN (both tabs). Container `inline-flex rounded-lg border bg-muted/40 p-0.5`, buttons `cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors`, active `bg-background shadow-sm`, inactive `text-muted-foreground hover:text-foreground`, with role=tablist/tab and aria-selected — the exact tokens the Due/Paid/All switch uses, so the two read as one family. Segments are All / Recurrent Plans / Credit Packs (values all | plan | pack). The two switches share a `flex flex-wrap` row so the second drops to its own line on a narrow screen instead of overflowing, and each carries its own aria-label ('Product type' and the existing 'Payment state') because two tablists side by side are otherwise indistinguishable to a screen reader.
player-subscriptions/_lib/product.ts (new) — parseProductFilter() and subscriptionSelect(). The embed is made `lesson_packages:package_id!inner(...)` for a named product and left OUTER for 'all'. That one modifier is load-bearing twice over: without !inner PostgREST returns every parent row and merely empties the embed, so both segments would show everything; and because it is absent under 'all', an enrolment whose template cannot be resolved is still listed there rather than disappearing from every view. It also makes count:'exact' count the joined set, so `total` agrees with the rows returned. An unrecognised `product` is answered with 400, never widened to 'all' — serving the whole list in reply to a request to narrow it answers a question nobody asked, and nothing on screen would reveal it.
THE ENROLLMENTS COUNT FOLLOWS THE FILTER — and the list now admits it pages. A '<n> of <total> enrollments' line reads the route's own count:'exact', taken with the product filter already applied in the database, so the number and the rows describe the same set (unlike the Payments tiles, which come from a separate SQL aggregate and say on screen that they do not follow the filter). A second line appears whenever fewer rows are loaded than exist: this endpoint pages and the list has no pager, so it has always shown one page of the filtered set — previously with nothing on screen saying so.
NO SILENT ERRORS IN THE ENROLLMENTS FETCH. `if (!res.ok) return;` left the previous list on screen with no indication it had gone stale, so an operator would read an unfiltered list as the answer to the filter they had just picked. Both the non-OK branch and the network catch now raise a destructive toast. This mattered more once the route gained a 400.
Unit tests (node:test, `npm run test:unit`): product.test.ts pins that an unrecognised product is rejected rather than widened, that only a named product makes the embed inner, and that the three selects differ by nothing but the join modifier; forward-search-params.test.ts pins repeated keys, the undefined-vs-empty distinction, the four deep-link params, that values are encoded so a redirect cannot be used to inject a path, and that the destination is never one of the two old paths. The filter's DB semantics were proved separately against the shared local database in a rolled-back transaction — including dropping the package_id FK INSIDE the transaction to manufacture an unresolvable row and confirm All still shows it while neither named segment does: all 6 = 3 plans + 2 packs + 1 unresolvable, rolled back with 0 rows and the FK left intact.
References updated deliberately rather than globally: the sidebar entry's href (label was already Members), TC-040's flow and steps, and the staff-docs Academy guide, whose title and nav label now say Members and which names both old links. The staff-docs page keeps its OWN /academy/subscriptions slug — it is a docs page, not the admin route, and staff-docs has no redirect layer to keep an old slug alive. v0.52.0's shipped entry below still reads 'the href stays /academy/subscriptions', which was true when it was written and is left alone.
0
v0.52.1Shipped
Academy Members → Payments — split the feed by product, give the tab its rhythm back
17 Sep
Two things Nestor asked for on the Payments tab. (1) THE STANDING 'no online payment provider is configured' BANNER IS GONE. Every facility has facilities.payment_provider NULL today, so it was permanent furniture on every visit, and it named a setting without linking anywhere to change it — a notice that can only be read, never acted on. The same fact is still told at the two moments it is actionable: the toast when Copy payment link finds no link, and the warning inside the reminder panel before a reminder goes out. (2) A PRODUCT FILTER — All products / Recurrent Plans / Credit Packs — beside Method and Channel. A Recurrent Plan re-bills on a cadence (its lesson_packages template declares a billing_period: 3 of 43 templates, 20 of 43 package payments on production); a Credit Pack is a one-off bank of sessions (40 templates, 23 payments). It is NOT the credits wallet — the feed reads payments.type IN ('package','membership'), so wallet top-ups have never been in this view and none was added. The filter is SERVER-SIDE, applied in the route before the page slice, because the feed is paginated and narrowing the 25 rows the browser holds would filter a page and present it as the club's answer. Deleting the banner also exposed the real spacing bug underneath it, fixed here rather than papered over.
SPACING — root cause, not a shim. AcademyPaymentsClient returns a bare fragment, the same shape academy-page-client and academy-setup-page-client use; those two sit directly under their page's `flex flex-col gap-6` shell and inherit its rhythm. Payments had that too while it was its own page at /academy/payments. It lost it when it became a tab: TabsContent is a plain block element, so the tiles grid, the Due/Paid switch, the filter row, the truncation notice, the table and the pager all stacked with NO gap between them and the banner was the only thing breaking them up. The payments TabsContent now carries `flex flex-col gap-6` — the same token as the page shells, one container for the whole tab — so the content keeps a consistent 24px rhythm with the banner gone. The Enrollments tab, which manages its own margins, is deliberately untouched.
academy-payments/_lib/feed.ts — new FeedProduct ('plan' | 'pack' | null) and packageProduct(), plus a `product` field on AcademyPaymentRow. The undefined/null distinction in packageProduct() is load-bearing: `null` means the template resolved and genuinely declares no period (a real credit pack), while `undefined` means the template was never resolved and the row is UNKNOWN, not a pack. Production has 0 package payments with no player_packages match, so that branch is defensive — but folding 'we did not look' into 'it is a pack' is how money lands under a wrong label the moment the data stops being perfect.
academy-payments/route.ts — reads a `product` param and classifies every one of the four sources at assembly time: package payments and package requests from the template's billing_period, unpaid player_packages from the embedded template (passing undefined, not ?? null, when the embed is missing), memberships as 'plan', lesson seats as null. The filter runs alongside method/channel — after assembly, which is where derived values must be filtered, but still before the page slice, so `total` and every page reflect it. An unrecognised value matches nothing rather than being ignored, because silently serving the unfiltered feed answers a question nobody asked.
MEMBERSHIPS ARE RECURRENT PLANS (8 payments on production). They are not lesson packages and carry no billing_period to read, but they are recurring by nature and cadenceLabel() already prints 'Subscription' against them, so the label stays true. LESSON SEATS ARE NEITHER and keep product null: a single seat is not a bank of sessions, and filing it under Credit Packs would put real money under a label that is false about it. Those rows are reachable under the default All products, so no filter hides money with no way to see it.
THE TILES DO NOT FOLLOW THE PRODUCT FILTER, AND THE SCREEN SAYS SO. They come from academy_payment_summary(), a single SQL aggregate taking a facility and a date range; teaching it about product type means changing an RPC signature in a migration, which a UI change does not carry. When a product filter is active a line under the tiles states that they cover all products and names what the list below is filtered to — the failure mode being avoided is a filtered list sitting silently beneath club-wide totals.
0
v0.52.0Shipped
Academy Members — Phase 2a: the screen becomes usable, billing keeps its own table
17 Sep
Phase 1 (v0.51.9, DEC-379) mounted the subscription model but shipped every write disabled, because player_subscriptions is the agreed destination while every live writer still writes player_packages — package sales, process-package-billing via the dispatch-package-billing cron, expire_player_packages, check_package_exhaustion, check-in, and the academy-payments settle path — and nothing synced the two. Cancelling on the new screen would have stopped the subscription on screen while billing carried on charging the member. Phase 2a turns the screen on WITHOUT moving a single writer, which stays Phase 2b's job. Two halves. (1) A one-directional trigger mirror projects player_packages onto player_subscriptions on every insert, update and delete, keyed on the same id — verified read-only against production: 206 of 206 subscriptions join their package on equal id with 0 orphans, and 210 of 210 session banks match on id and parent. It cannot loop because nothing on the subscription side writes back, and it deliberately carries NO pg_trigger_depth guard: check_package_exhaustion updates player_packages from a trigger on player_package_sessions at depth 2, so a depth guard would silently drop exactly the active-to-exhausted transitions the mirror exists to carry. (2) Every action on the screen now calls the EXISTING player-packages routes, so the billing cron, the expiry cron and check-in all see the change and the mirror updates the row the screen reads. Measured before anything was written: 0 of 206 subscriptions and 0 of 210 session banks differ from what the mirror produces, so the migration's one-shot reconciliation repairs nothing today and only touches rows that actually drift. Also in this release: the Academy nav rename Nestor asked for — Subscriptions becomes Members and Students becomes Groups, labels only, both URLs unchanged.
supabase/migrations/20260916203446_mirror_player_packages_to_subscriptions.sql (new) — mirror_player_package_to_subscription() and mirror_player_package_session_to_subscription(), both SECURITY DEFINER with search_path pinned to public, pg_temp and revoked from PUBLIC/anon/authenticated, driven by AFTER INSERT OR UPDATE OR DELETE triggers on player_packages and player_package_sessions. The session mirror projects its parent package first, because player_subscription_sessions.player_subscription_id is a NOT NULL FK and a bank can be inserted before the mirror has seen its package.
expires_at's 90-day fallback for a package with no expiry is applied at INSERT and PRESERVED on every UPDATE. 62 of production's 206 packages have expires_at NULL; re-deriving now() + 90 days on each mirror write would have walked those members' access windows forward every time anything touched the package.
One-shot reconciliation at the end of the migration, reporting counts with RAISE NOTICE. It deletes subscriptions whose package is gone, then re-syncs only rows whose mapped columns actually differ (status compared THROUGH compute_subscription_status, since that is what the BEFORE UPDATE trigger stores) — so on an in-sync database it writes nothing and does not churn updated_at across the estate.
player-subscriptions-list.tsx — WRITES_DISABLED and the amber read-only banner are gone. Mark as Paid, Extend and Cancel now PATCH /api/admin/player-packages/[id] with { mark_paid }, { expires_at } and { status: 'cancelled', cascade: true }; a new Adjust Sessions dialog PATCHes { session_adjustments, reason }, which the route hands to the adjust_package_sessions RPC so the counter change and its package_session_transactions ledger row share one transaction. A subscription id addresses its package directly because the backfill used player_packages.id as the primary key.
assign-subscription-dialog.tsx — assigning now POSTs /api/admin/player-packages, the same endpoint the Player Packages screen uses, which inserts the package, grants session banks from package_items and records the payments row. The custom start-date and duration inputs were REMOVED rather than left on screen doing nothing: that route computes expires_at from the package's own valid_days. Per-enrolment date windows return in Phase 2b with create_subscription_from_package.
RENEW is the one action left disabled, and it is not on the menu. No admin endpoint renews a player_packages row — the only renewal path is settling a billing-cycle payment_request, which calls reset_package_sessions, and that lives on the Payments tab. A renew button here would have created a subscription with no package behind it: invisible to billing and deleted by the reconciliation.
player-subscriptions/_lib/access.ts — the PATCH/cancel/extend/renew/POST handlers under that prefix are documented as writers against a projection now: anything they change is reverted the next time the package is touched. They stay in place, still WRITE_ROLES-gated, because Phase 2b moves the writers onto them for real.
SECURITY — player-packages write paths narrowed to owner/admin/manager. Both routes authorised with a bare requireFacilityAccess and run on createAdminClient (RLS bypassed), so any facility role — coach, cashier, staff, member — could mark a package paid, extend it, cancel it into lessons/bookings/Stripe, adjust its session counters or assign one. Latent while it was only reachable from Academy > Setup; Phase 2a puts four of those actions on the Members screen. New player-packages/_lib/access.ts mirrors player-subscriptions/_lib/access.ts (the writeRoles option belongs to requireFacilityContext, which resolves facility by slug and cannot serve these id-resolved routes). READS are untouched — a coach legitimately reads packages for the lesson form. POST now also authorises BEFORE findOrCreateUserByEmail, so a refused assign no longer creates an auth user as a side effect. Pinned by a node:test suite over every role.
Academy nav rename (labels only, no route changes): Subscriptions → Members with the Banknote icon replaced by IdCard, and Students → Groups moved directly below Members with a UsersRound icon. Both hrefs are unchanged (/academy/subscriptions and /academy/students), so the /academy/payments redirect and every bookmark still land. The inner tab formerly called Members is relabelled Enrollments — a tab repeating the page name told an operator nothing — while its key stays ?tab=members because that is a published deep-link surface.
Known and reported rather than hidden: a package hand-set to 'expired' while it has no expiry date reads as 'active' on the subscription side, because the table's own BEFORE UPDATE trigger re-derives non-pass-through statuses from the date window. 0 such rows exist on production (all 62 NULL-expiry packages are active, exhausted or cancelled), and a regression test pins the behaviour so a later phase cannot change it unnoticed.
0
v0.51.9Shipped
Academy Subscriptions — Phase 1: the model reaches production, read-only
17 Sep
The subscription model (player_subscriptions, player_subscription_sessions, four RPCs, every /api/admin/player-subscriptions route) was merged on 4 Sep and shipped dark: no screen mounted it, and the tables never actually reached production. Two reasons, both found and fixed here. (1) The migration aborted on real data: player_subscriptions_status_check admitted five statuses, but the step-5 backfill copies player_packages.status through verbatim and 59 of production's 206 rows are 'exhausted' — the first one rolled the whole transaction back. The same CHECK also rejected 'pending', which compute_subscription_status returns for a future-dated enrolment and which both RPCs and the BEFORE UPDATE trigger write, so a future-dated subscription could never be created, renewed or edited. The CHECK is now a strict superset of player_packages_status_check, so the backfill cannot produce an out-of-domain value. (2) The Members tab 500'd: it embedded profile:user_id(email, first_name, last_name), but user_id is a FK to auth.users, which PostgREST does not expose, and this database has no public.profiles and no public.users at all. Identity now resolves out of public.customers, the same path the Payments tab of this very screen and players/search already use. OWNER DECISION (DEC-379): player_subscriptions is the destination and player_packages is legacy, but every live writer still writes player_packages and nothing syncs the two — so Phase 1 ships the tables, the security fixes and the screen with READS ONLY. Every write action (cancel, renew, extend, mark paid, assign) is visibly disabled with a stated reason, because cancelling here while the billing cron still reads the player_packages row would stop the subscription on screen and keep charging the member. Phase 2 moves the writers, then turns the actions on — one flag.
supabase/migrations/20260904000000_academy_subscription_model.sql — status CHECK widened from ('active','expiring_soon','expired','suspended','cancelled') to add 'pending' and 'exhausted'. compute_subscription_status now passes 'exhausted' through alongside 'cancelled'/'suspended': a used-up session bank is not a fact about dates, so recomputing would have relabelled all 59 backfilled rows 'active' the first time anything updated them. Edited in place rather than superseded — verified applied NOWHERE (production's schema_migrations has no entry and the tables are absent), so a fresh db reset passing is only possible this way.
supabase/migrations/20260910040655_fix_player_subscriptions_missing_columns.sql — its session re-copy had no orphan guard while 20260904000000's step 6 does, so any package step 5 skipped would raise a FK violation and abort the deploy. Now an inner join on player_subscriptions, reproducing step 6 exactly. Its copy of compute_subscription_status kept byte-identical to the first file's, since this one runs last and silently wins any drift.
apps/admin/src/lib/customer-profiles.ts (new) — fetchCustomerProfiles() and searchCustomerIds(), the shared identity path for both subscription routes. Search is word-split and stripped of PostgREST's ,()" syntax (BUG-022 guard) with %/_ escaped, and capped at 150 candidate ids because a single .in() carrying 250+ uuids returns HTTP 414 (found in v0.51.6). Hitting the cap is reported as search_truncated and shown to the operator rather than silently returning a short list.
player-subscriptions/route.ts + dashboard/subscriptions-due/route.ts — both dropped the unresolvable profile:user_id embed and attach profiles from customers afterwards; a failed identity lookup is a 503, never a page of 'Unknown' that reads as real data. `profile` keeps the exact shape the embed promised, so the Members tab reads it unchanged.
player-subscriptions-list.tsx + assign-subscription-dialog.tsx — Mark as Paid, Adjust Sessions, Extend, Cancel and Assign are all disabled behind a single WRITES_DISABLED flag, with an amber notice naming the reason in plain language. Handlers are left wired up so Phase 2 flips the flag rather than rebuilding the UI.
academy/payments/page.tsx — the redirect into the Subscriptions tab now carries the query string across (?view=, ?overdue=, ?open=<id> all survive, repeated keys re-emitted as repeats). It previously dropped it, turning a link aimed at one overdue request into a bare list.
Stale references to the deleted Academy → Reports screen cleared: TC-026e and TC-073f removed (no page left to assert against), TC-029's reports test reduced to the lesson creation three later tests depend on, the staff-docs Reports guide replaced by an Academy Subscriptions guide (nav + overview links updated), and the TC-040 catalog entry re-pointed at the destination that absorbed it.
0
v0.51.7Shipped
Terminal Guest CRM — level, tags, status, staff capture on the till
17 Sep
Phase 1 of the guest CRM redesign, scoped to the terminal (staff-facing till). Admin's CRM backend (crm_tags, crm_customer_tags, crm_notes, player_ratings) already existed but surfaced almost nothing on the floor — the terminal's customer modal was notes-only and only reachable from a standalone Customers tab, never from checkout. This adds a computed relationship status (New/Regular/VIP/At risk, derived from ticket history, never staff-entered), a padel skill level, and reuses the existing tags system as club-configurable preference chips — all shown at a glance in the profile header and editable in a new Staff Capture card. The profile is now reachable from checkout: tapping the attached customer's name in the Order panel opens the same modal, no new container, no button that mutates the live ticket (attaching a customer stays the till's own job). Level writes to the existing player_ratings table (level_type: coach_set) rather than a new one — an initial version of this PR added a parallel crm_customer_levels table before this was caught and reworked; player_ratings already had coach_set/admin_override level types reserved for exactly this (per DEC-283 multi-leveling), just no write path anywhere. Verified live: setting a level or tag in terminal shows up on admin's existing full CRM page (/{facility}/crm/customers/[id]) automatically, zero admin code changes. Three owner decisions closed the gaps the original PR left open: (1) staff and cashiers get coach_set only, so neither can overwrite an admin_override — RLS evaluates UPDATE's USING clause against the OLD row, so an admin's override is their last word; (2) cashiers do not edit tags, and rather than widening the crm_customer_tags policies to let them, the terminal shows a cashier the assigned tags read-only (level editing stays available to them); (3) assigning an already-assigned tag no longer errors — the write is now an INSERT ... ON CONFLICT DO NOTHING against the table's real unique constraint instead of an upsert that would have needed an UPDATE policy this table deliberately lacks, making the toggle idempotent in both directions. Admin's OTHER, thinner booking-history panel (customer-detail-panel.tsx) still isn't updated — separate component, lower priority now that the full CRM page already covers this. POS (apps/pos) has its own separate customer modal with neither tags nor level yet — real remaining work, not yet started.
supabase/migrations/20260915183051_staff_can_set_local_player_ratings.sql — additive RLS policy on player_ratings granting staff/cashier write access to level_type 'coach_set' ONLY. admin_override stays exclusively with the pre-existing 'Admins and coaches can manage player ratings' policy (owner/admin/manager/coach), which is left untouched; those roles are deliberately not re-listed in the new policy since RLS policies are OR'ed and they already hold every level_type. Consumer-sourced level types untouched. Rebase-prep note: original PR migration was timestamped 20260904040041 — re-cut with a current timestamp and DROP POLICY IF EXISTS guards (both the old and new policy names) so it's safely re-runnable.
apps/terminal/src/queries/use-customer-level.ts + mutations/use-customer-level-mutations.ts — read/upsert a customer's level. level_type is a module constant, not part of SetCustomerLevelInput, so no caller can widen it to admin_override; the RLS policy is the real gate.
apps/terminal/src/queries/use-customer-tags.ts + mutations/use-customer-tag-mutations.ts — facility tag list, a customer's assigned tags, idempotent toggle (assign = INSERT ... ON CONFLICT DO NOTHING via PostgREST resolution=ignore-duplicates on crm_customer_tags_facility_id_profile_id_tag_id_key; un-assign = DELETE), create a new facility tag on the fly (staff+, matches admin's POST /api/admin/crm/tags created_by attribution). Exports TAG_EDITOR_ROLES/canEditCustomerTags mirroring the existing canApplyManualDiscount pattern.
customer-detail-modal.tsx — header gains a LVL badge, computed status chip, and assigned-tags row (visible to everyone who can read); Overview tab gains a Staff Capture card (level selector + tag chips + inline new-tag input) above the existing Notes card. Tag controls are gated on the facility role from AuthProvider: staff+ get toggles and the new-tag input, a cashier gets the assigned tags as read-only chips and keeps full level editing.
cart-sidebar.tsx + customer-selector.tsx — the attached customer's name in the Order panel is now a tap target that opens the existing profile modal (pull, not push — no ticket-mutating action inside the modal).
0
v0.51.8Shipped
Report emails — net categories, discounts & returns detail, look-ahead horizons; POS custom amounts get a category
17 Sep
Owner review of the live Bangkok/Bali emails, rebuilt from PR #1083 on top of everything main has shipped since (partially-refunded payments 20260911100840, Returns/Comps ALB-84/DEC-375 #1097, and the academy→POS forward-mirror pause #1114 — all three preserved, none reverted). Club Business Report: Sales by category is NET (was gross — it summed to exactly Gross Sales), each category lists its top 3 items and the separate 'Top sellers' table is gone; a new Discounts & returns block under the financial summary itemizes every discount (name, effective %, bills) and every refund (ticket, reason, time) WITHOUT repeating their totals — the Gross→Returns→Discounts→Comps→Net ladder stays the single source for those; Tips / Avg tip rate / Fees hide while zero; the daily Looking ahead shows Tomorrow / Next 7 days / Next 14 days each with a pace arrow; the 'On court' tile row is removed (the RPC still returns on_court). The POS Daily Sales Report gets net item sales, the same Discounts & Returns detail block, Tips hidden when zero, payment methods netted per refund (DEC-344 parity), a 'Credits' label and a `preview` flag. Root cause of the permanent 'Uncategorized' bucket found and closed at the source: the till's Custom Amount now requires a category, the (paused) academy bridge resolves the club's academy category, and a one-shot REVERSIBLE backfill fills the pre-24-July history. See DEC-377 / DEC-378.
supabase/migrations/20260916181537_business_report_net_categories_discounts_horizons.sql — CREATE OR REPLACE facility_business_report on main's current body: categories NET with per-item proration of item/order discounts and refunds (refund-date basis, matching its own v_returns per DEC-361), nested `items` per category, `discounts_detail`, `returns_detail`, `looking_ahead_horizons` (daily only). Keeps 'partially_refunded' and every oi.is_foc = false exclusion from main. `top_items` kept gross for API compatibility.
supabase/migrations/20260916181539_pos_daily_sales_summary_net_items_returns_detail.sql — net item sales with the sale line's category snapshot as a fallback behind the catalog chain, discounts/returns detail, payment methods netted per refund with 'partially_refunded'/'refunded' admitted. Keeps main's ORDER-date returns basis (DEC-375) rather than #1083's refund-date switch; every refund-touching block is scoped to match it, so the emailed list always adds up to the emailed Returns line.
supabase/migrations/20260916181542_academy_pos_category_resolver_and_bridge_category.sql — academy_pos_category_id(facility) resolver, service_role only (REVOKE from PUBLIC + anon + authenticated, GRANT to service_role — #1087's three-revoke pattern; #1083 revoked only PUBLIC, leaving anon able to create categories at any facility). Advisory-locked per facility because pos_categories has no unique (facility_id, name) index. record_academy_payment_in_pos_row rebuilt from main's PAUSED body — the pause is untouched and the category lands in the dormant forward path.
supabase/migrations/20260916181544_backfill_pos_order_items_category_reversible.sql — one-shot, NULL-only backfill that writes an undo snapshot (pos_order_items_category_backfill) in the same data-modifying CTE as the UPDATE, plus pos_categories_created_by_backfill for any category the resolver has to invent. Exact UNDO statements in the header. Prod scope measured read-only: 1,465 uncategorised lines of 7,810, of which 755 catalog-resolvable + 22 academy = 777 to fill across 4 facilities; 688 custom-amount lines stay NULL. Measured on prod after applying: 1,465 uncategorised lines across Rama IV (1,028), PTP Bali (402), The Club (28) and WareHaus Club (7); 777 filled (755 catalog + 22 academy), 688 custom-amount lines left NULL by design. 'Academy Programs' was invented at PTP Bali and WareHaus Club — both had academy-payment lines (source_id LIKE 'academy_payment:%') but no root category matching 'academy%'; Rama IV already had one. Both inventions are recorded in pos_categories_created_by_backfill, so the undo deletes exactly those two and nothing else.
supabase/functions/_shared/business-report-html.ts — merged category/item table, Discounts & returns block rendered under the financial toggle (no 11th section key), quiet Tips/Fees rows, three-horizon Looking ahead table, On court row removed. Every new section degrades to the old rendering when a stale RPC omits its keys.
supabase/functions/send-daily-sales-report/index.ts — Discounts & Returns detail block, Item Sales labelled net, Tips hidden when zero, 'Credits' label, `preview: true` returns { subject, html } and sends nothing. The Returns and Comps ladder rows shipped by #1097 are left exactly as they are — #1083's own versions of those rows were dropped as already-shipped.
apps/terminal + apps/pos keypad.tsx — Custom Amount requires a root category (remembered per session); both stores carry categoryId into pos_order_items.category_id via the existing checkout payload builders; apps/pos quick-item lines inherit the open category tab.
apps/admin/e2e/tc-284-business-report-net-discounts-returns.e2e.ts (seeded discount + refund → net ฿1,500 category, discount/refund rows, horizons, no Tips/Fees/On court/Top sellers, and the Comps ladder row still present) and apps/terminal/e2e/custom-amount-category.spec.ts (keypad → cart → cash checkout → DB row carries the category).
2 E2E tests
v0.51.6Shipped
POS/Terminal Transactions — search covers the full selected date range (ALB-89)
16 Sep
Search was purely client-side, filtering transactions.filter(...) over whatever pages useTransactionsInfinite had already loaded (50 rows/page, ALB-79) — a facility with 1000+ payments in range searching a real term only ever checked whichever page happened to be loaded, invisible unless the user clicked 'Load more' repeatedly first, which defeats the point of search. Both apps now debounce the search input 300ms and query a new search_pos_transactions RPC (server-side, scoped to the SAME facility/location/method/status/date-range filters already applied — never the whole table) instead of filtering the loaded array. The RPC returns matching payment ids only; a chunked follow-up fetch (chunkArray, packages/common/src/pagination.ts) re-hydrates them through the existing TRANSACTIONS_SELECT so the joined-row shape stays defined in exactly one place rather than duplicated into SQL. Covers every field the client-side filter already checked (order ref, tax-invoice #, buyer tax ID, customer name/email/phone, amount, method, card brand/last4, status) plus order item name (apps/terminal's existing client-side superset). 'Load more' (browse mode) is unchanged and hidden while a search is active. Real bug found and fixed while verifying at realistic scale: a single .in() request with 250+ uuids returns HTTP 414 'Request-URI Too Large' from PostgREST's gateway — a search matching more than ~200 rows (SEARCH_RESULT_LIMIT caps display at 500) would have hit this every time without chunking the id re-fetch.
supabase/migrations/20260916053008_search_pos_transactions_rpc.sql — search_pos_transactions(facility_id, location_id, date_from, date_to, method, status, search, limit): plain ILIKE scan (same shape as crm_search_customers, no new indexes — existing facility/date/customer btree indexes already narrow the scan before the ILIKE runs). SECURITY DEFINER + explicit user_facility_role() authorization check, full REVOKE-then-explicit-GRANT privilege block, matching pos_transactions_summary's established pattern.
apps/pos + apps/terminal use-transactions.ts — useTransactionsSearch(options, limit): calls the RPC, then chunks the returned ids (150/request, chunkArray) before re-fetching the full joined row shape — a single un-chunked .in() request would 414 once matches exceed ~200.
apps/pos + apps/terminal transactions/page.tsx — 300ms debounce (new hooks/use-debounce.ts) before the search state feeds the query; while searching, results come from the server instead of the loaded infinite-scroll pages, 'Load more' hides, and a truncation note shows if matches hit the 500-result cap.
New e2e spec, apps/pos/e2e/transactions-search-full-range.spec.ts — seeds 520 payments, plants one marker 450 rows past the first page, proves it's invisible without search and found with it; also searches a common field (520 matches, past the 500 cap) to stress the chunked id re-fetch and prove it doesn't 414.
1 E2E tests
v0.51.5Shipped
POS/Terminal Transactions — tax-invoice number as primary identifier (ALB-87)
15 Sep
Track B follow-up to ALB-80 (fixed the underlying next_invoice_number() RPC). pos_orders.tax_invoice_number/tax_invoice_type/buyer_tax_number were being written correctly on new orders since ALB-80 but never surfaced anywhere — the Transactions list and order-detail views (both apps/pos and apps/terminal) still showed only the internal '#<last 6 of order UUID>' ref, meaningless to an accountant reconciling books against the printed tax invoice. Pure display/search/CSV change, no DB/query changes needed (pos_orders.* was already selected everywhere this touches). tax_invoice_number now displays as the primary identifier when set, falling back to the short ref for pre-ALB-80 orders and non-VAT facilities (verified this fallback explicitly — null/undefined/empty string all handled, never blank). tax_invoice_type is badged ('Full Tax Invoice' / 'Simplified Tax Invoice', matching the two values create_pos_order_atomic actually writes). buyer_tax_number shows in the customer column/area when set. All three fields are searchable in the free-text box and included in CSV export (both sale and refund rows) alongside the existing columns. Centralized in packages/common/src/pos-orders.ts (transactionDisplayNumber/taxInvoiceTypeLabel) per this repo's single-source-of-truth convention, with unit tests. Found and fixed a real regression while verifying: both existing ALB-79 e2e specs (apps/pos and apps/terminal) asserted the CSV header with an exact string match — the new columns would have silently broken both; updated to the new header shape and re-ran, all green.
packages/common/src/pos-orders.ts — transactionDisplayNumber(orderId, taxInvoiceNumber) (prefers the invoice number, falls back to '#<last6 uppercased>') and taxInvoiceTypeLabel/TAX_INVOICE_TYPE_LABELS ('full'/'simplified', falling back to the raw value for an unrecognized type rather than disappearing). New pos-orders.test.ts (9 tests) covering the null/undefined/empty-string fallback paths explicitly.
apps/pos + apps/terminal Transactions list (transactions/page.tsx) — list rows show the doc number in place of the short ref, badge the invoice type, show buyer_tax_number under the customer name when set, and the free-text search box now matches tax_invoice_number/buyer_tax_number too.
apps/pos's components/orders/order-detail-modal.tsx and apps/terminal's components/terminal/order-detail-modal.tsx — header identifier + invoice-type badge; apps/pos adds a 'Tax Invoice' summary block (document #/type/buyer tax ID) to the Receipt tab, apps/terminal adds the same three rows to the Overview tab.
apps/pos + apps/terminal transactions/[transactionId]/page.tsx (the standalone payment-detail route) — Order row now shows the doc number, plus Invoice Type / Buyer Tax ID rows when set.
CSV export (both apps' downloadTransactionsCsv, both sale and refund rows) — three new columns: Tax Invoice #, Tax Invoice Type, Buyer Tax ID. Required extending RefundExportRow's type + REFUND_EXPORT_SELECT in both apps' use-transactions.ts (the refund-export query previously selected only status + customer off pos_orders).
Regression fix: apps/pos/e2e/transactions-date-range-pagination-export.spec.ts and apps/terminal/e2e/transactions-date-range-export.spec.ts both had an exact-match assertion on the old 8-column CSV header — updated to the new 11-column header. Both specs re-run green (4 tests total) after the update, confirming no other assertion in either file depended on column position.
4 E2E tests
v0.51.4Shipped
POS/Terminal Transactions page — date-range filter, real pagination, server-side stat tiles, CSV export (ALB-79)
15 Sep
Track B of the accountant-facing gap-parity audit against Square. The Transactions page (apps/pos) / Sales page (apps/terminal, same route) previously hard-capped at ~100 rows with no date filter and no export — an operator with more than ~100 payments in a period couldn't see or reconcile the rest. Adds a facility-timezone date-range filter (DateRangePicker/useReportDateRange, ported to apps/terminal since it's a separate Next.js app with no per-location timezone infrastructure of its own), real keyset pagination (useTransactionsInfinite, page size 50, replacing the hard cap), a new pos_transactions_summary RPC so the Collected/Transactions/Avg Sale/Refunded stat tiles reflect the FULL filtered range rather than just whatever pages are loaded, and CSV export (one row per payment, one per refund) covering the full range server-side. Day groupings now cut at the facility's reporting timezone instead of browser-local midnight. Two /code-review --high findings fixed before merge: refunds were being date-filtered by their parent payment's created_at instead of the refund's own processed_at (both in the RPC and the CSV export — a payment made Jan 1 and refunded Jan 20 would wrongly attribute to whichever range contained the payment, not the refund), and a failed CSV export gave zero user-facing feedback (now toast.error).
supabase/migrations/20260915070503_pos_transactions_summary_rpc.sql — pos_transactions_summary(facility_id, location_id, from, to, method, status): server-side aggregate matching the pages' existing isCompletedSale/COMPLETED_SALE_STATUSES semantics (voided orders never count toward Collected/Avg Sale/Refunded). Refunds summed on their own coalesce(processed_at, created_at), not the parent payment's date. Full REVOKE-then-explicit-GRANT privilege block (PUBLIC/anon revoked, authenticated + service_role granted), matching 20260909060247_reorder_courts_rpc's pattern.
apps/pos + apps/terminal src/queries/use-transactions.ts — useTransactionsInfinite (keyset-paginated on created_at,id, mirrors apps/terminal's existing usePmsBookings pattern), useTransactionsSummary (RPC-backed), fetchAllTransactionsForExport + fetchAllRefundsForExport (independent date-scoped fetches for CSV export, sales by payment date, refunds by their own date).
apps/terminal/src/components/ui/date-range-picker.tsx — new file, ports apps/pos's DateRangePicker/useReportDateRange/buildPreset; the one real difference is reading useAuth().timezone (facility-level) instead of pos's per-location useReportTimezone, since apps/terminal has no useLocations hook.
9 pre-existing invalidateQueries(["transactions"]) call sites (order/void/payment mutations in both apps, plus apps/terminal's refund mutation in order-detail-modal.tsx, found while wiring this up) updated to also invalidate the new "transactions-infinite"/"transactions-summary" query keys — invalidateQueries matches by key prefix, so the old single-key invalidation silently stopped refreshing the page's list/stats after a void, refund, or payment edit.
apps/pos/e2e/transactions-date-range-pagination-export.spec.ts (3 tests: date-range filter incl. stat-tile delta, Load more pagination, CSV export content incl. refund row) and apps/terminal/e2e/transactions-date-range-export.spec.ts (1 test) — both green alongside the pre-existing apps/pos/e2e/void-order.spec.ts (8 tests) and apps/terminal/e2e/refund-reverses-effects.spec.ts (2 tests) regression suites.
4 E2E tests
v0.51.3Shipped
POS — manual order void/un-void for owner/admin/manager (Void Integrity Plan)
7 Sep
Follow-up to v0.51.1 (DEC-333): that incident's cleanup (20260907072617_pos_order_void_support.sql, voiding the 27 confirmed Bangkok duplicate orders) was a one-off manual migration — every future duplicate/mistake would need an engineer again. Adds a real Void/Un-void action so an owner, admin, or manager can correct a completed order themselves. See DEC-334.
supabase/migrations/20260907080933_pos_order_void_rpc.sql — void_pos_order(order_id, reason) / unvoid_pos_order(order_id) RPCs. Authorization lives in the pos_orders_prevent_completed_mutation trigger (not just the RPC), since pos_orders_update RLS already lets 'staff' run a raw UPDATE — an RPC-only check would be bypassable with a plain .update() from the client. Each transition is role-gated (owner/admin/manager), reasoned, and field-isolated so a void/un-void can never double as a side-channel edit to amounts/items/payments; once voided, the same trigger now blocks every other mutation on that row too.
Frontend: Void/Un-void buttons on all three order-detail-modal call sites (apps/pos, and both variants in apps/terminal), gated on role + order status, via a single shared VoidOrderDialog(mode: 'void'|'unvoid') per app and a shared VOID_ORDER_ROLES constant (packages/common/src/roles.ts).
Verified locally at the SQL layer with simulated JWT sessions: staff rejected via both the RPC and a raw UPDATE bypass; a caller with no facility role at all rejected (closes a NULL-logic gap found and fixed before shipping); manager void requires a non-empty reason; un-void restores completed and clears void fields; double-void rejected; a forged voided_by rejected.
apps/pos/e2e/void-order.spec.ts — staff blocked from the Void action; manager void requires a reason and is verified against the DB; the void reason is shown before un-voiding; un-void restores completed and clears the audit fields, verified against the DB.
DEC-334 (trigger-vs-RPC authorization placement, the NULL-role fix, and correctness bugs found across three code-review rounds — a stale order-detail cache key, a voided_by forgery gap, and voided orders inflating totals at multiple sites across apps/pos, apps/terminal, and admin's CRM routes, root-caused into a shared COMPLETED_SALE_STATUSES constant) logged with full rationale.
1 E2E tests
v0.51.2Shipped
Club Business Report — payment-link category gap, returns/fees accuracy, atomic-RPC integration
7 Sep
Bug-fix-only follow-up, unrelated to v0.51.0's Academy Payments work, prompted by a real Pad Thai Padel report (฿192,488 Total Collected vs. ฿108,505 Sales by Category). Two confirmed root causes: (1) every Stripe Payment Link / QR-code checkout wrote a pos_orders row with the full paid amount but no pos_order_items rows at all — facility_business_report's categories/top_items (which read pos_order_items only) silently excluded that revenue while gross_sales/total_collected (read from pos_orders) counted it; (2) the RPC computed a `returns` figure but never actually subtracted it from net_sales/total_collected, so the email's Returns line had no effect on the totals above it. Fixed in a new CREATE OR REPLACE facility_business_report migration (returns subtracted from net_sales/total_collected/fees/payment_methods, all on the order's own created_at basis rather than the refund's). Also accounts for in-flight PR #1062 (Credits payment method, not yet merged): added the missing 'credits' label to the hand-maintained payment-methods map. Originally fixed the category gap by hand-inserting pos_order_items from both the webhook and cron — but this branch was rebased onto v0.51.1 (#1063's create_pos_order_atomic, merged 3 days after this branch started, fixing 36 duplicate-order clusters in prod) mid-flight, which replaced every hand-rolled order-creation path with one atomic, idempotent RPC. Rather than ship a category-gap fix on a pattern #1063 had just proven unsafe in prod, the fix was redone on top of the atomic RPC instead: check-payment-links now populates p_line_items/p_discounts/p_stock_targets on its existing create_pos_order_atomic call (closing the category gap the RPC-only call left open), and pos-stripe-webhook — untouched by #1063, so still on the exact vulnerable non-atomic pattern #1063 fixed everywhere else — was migrated to call the same RPC with the same `payment_link:<id>` idempotency key the cron uses, so a webhook-first or cron-first race now converges on one order via the RPC's own idempotency short-circuit instead of two independent hand-rolled insert sequences. Seven /code-review passes on the pre-#1063 version, then a full re-verification pass after the rebase, surfaced and fixed: a real crash risk (missing null-guard on a cart array), an incomplete returns-netting fix (payment_methods' and fees' own status filters excluded refunded payments before the netting logic could run), item-level and ticket-level discount omission from the payment-link paths (till-checkout applies them, payment-links didn't), prepaid-booking phantom revenue, a stale-cart_json race at 'Send payment link' time, and refund-netting performance (LATERAL → CTE). categories/top_items still don't net returns (pos_refunds has no line-item attribution in the schema) — left as an explicitly documented gap. A ninth /code-review pass on the merge itself found ten further real gaps: a split-item-editing door that could silently rewrite a payment-frozen ticket's cart_json (same bug class, new door), a deploy-transition risk where a legacy pre-idempotency-key order could collide with pos_orders' own source_id unique index on Stripe redelivery, apps/pos's own in-app reports missing the exact refund-netting fix that shipped in the email, avg_order_cents not netting returns either, a missing Stripe-amount-vs-derived-breakdown consistency check, a payment-link cart_json save that bypassed split-sibling discount sync, RPC-payload assembly duplicated a second time in the same runtime, a sequential-instead-of-parallel webhook query pair, a refund-aggregate duplication investigated but left as two queries (with their filters aligned) given the RPC's timeout history, and a Deno/browser readTicketCart divergence on malformed cart entries. All ten fixed. See DEC-333 (superseded approach), DEC-341 through DEC-359.
supabase/functions/pos-stripe-webhook/index.ts — migrated off its own hand-rolled order+line-items+modifiers+stock-decrement insert sequence onto create_pos_order_atomic, keyed by payment_link:<payment_link_id> (same key format as the cron, so both converge on one order via the RPC's own idempotency short-circuit); confirmed the function's Supabase client uses the service-role key, required for the RPC's auth.role() = 'service_role' bypass.
apps/terminal/src/app/api/cron/check-payment-links/route.ts — extends v0.51.1's existing create_pos_order_atomic call (which only passed p_order/p_payments) with p_line_items (nested modifiers, kitchen_sent_qty, prepaid-booking exclusion via payableItems, item-level discounts), p_discounts (order-level + per-item, matching the migration's exact per-entry shape), and p_stock_targets (replacing the old hand-rolled stock-decrement loop); fetches the ticket's cart_json fresh immediately before use rather than from a batch snapshot, closing a stale-cart race.
supabase/migrations/20260904065307_fix_business_report_returns_deduction.sql — CREATE OR REPLACE facility_business_report: v_returns computed once and subtracted from net_sales/total_collected/fees; payment_methods rewritten from a LEFT JOIN LATERAL to two CTEs (scoped_payments, refund_totals) for performance, and nets each payment's own refunds (status filter widened to admit 'refunded' alongside captured/settled, excluding only 'failed'); all refund scoping keyed to the underlying order's created_at, not the refund's own, matching apps/pos's existing Sales Summary convention; categories/top_items explicitly commented as a known, unfixed gap (no line-item refund attribution exists in the schema). Unaffected by #1063 (confirmed no upstream commit since this branch started touches this RPC or business-report-html.ts).
supabase/functions/_shared/business-report-html.ts — PAYMENT_METHOD_LABELS gains credits: "Credits", matching PR #1062's packages/common/src/payment-methods.ts label exactly (read on that branch, not guessed) — inert today via the map's ?? method fallback, active once #1062 merges.
apps/terminal/src/lib/line-item-pricing.ts — extended with payableItems/grossLineTotalCents/computeGrossSubtotal/computeTicketDiscountCents, deduping pricing logic that till-checkout, ticket-modal, and the payment-link cron each need identically.
apps/terminal/src/components/terminal/ticket-modal.tsx + generate-payment-link/route.ts + use-ticket-mutations.ts — 'Send payment link' now persists the ticket's live cart_json at send time instead of relying on whatever was last saved, closing a gap where edits made just before sending a link were silently dropped from the eventual order.
DEC-341 through DEC-352 (returns netted on order-date basis — later superseded by DEC-361's refund/payment-date basis, see below — payment_methods refund attribution, status-filter fixes, prepaid-booking exclusion, stale-cart_json fixes, pricing-logic dedup, perf rewrite, categories/top_items documented gap) plus a new decision recording why the fix's write-path approach changed mid-branch (superseded by #1063/DEC-333) and that the webhook migration closes a duplicate-order vulnerability class that predates this branch, discovered while integrating with #1063 rather than caused by it.
Ninth /code-review pass (DEC-354 through DEC-359): split-item-detail-modal.tsx gated against writing a payment-locked ticket's cart_json (own ticket and siblings); a new backfill migration plus a client-side unique_violation fallback close the source_id/idempotency_key deploy-transition window; apps/pos/src/queries/use-reports.ts's payment-methods and full reports now net refunds the same way the email does; avg_order_cents nets returns; both the cron and webhook now assert Stripe's charged amount matches the derived subtotal/discount breakdown before creating an order; generate-payment-link/route.ts propagates split-discount changes to sibling tickets; a new order-rpc-payload.ts shares RPC-payload assembly between use-order-mutations.ts and the cron; the webhook's location and cart_json fetches now run in parallel; v_returns' filter now matches payment_methods' refund_totals exactly (merging the two queries investigated and documented as not worth the restructuring risk); and ticket-utils.ts's readTicketCart now matches the webhook's own more-defensive optional chaining.
0
v0.51.1Shipped
POS/Terminal checkout — atomic, idempotent order creation (prod-incident fix)
5 Sep
Prod-incident fix, code-complete pending merge + deploy. Root cause: manual checkout in both apps/terminal and apps/pos (independent, near-duplicate copies of the same mutation) inserted pos_orders with status:'completed' FIRST, then 6+ more separate, non-atomic inserts/side effects (line items, modifiers, service charges, discounts, taxes, payment, stock decrement, gift-card redemption, discount-ledger commit, drawer cash flow, activity log). A failure partway through left a real completed order behind — and since none of the Supabase calls in this path used .throwOnError(), the thrown value was a plain {message,details,code} object, not an Error instance, so friendly-error.ts's `error instanceof Error` check failed and staff saw literally '[object Object]', prompting a retry on the same still-populated cart that created a second, fully independent completed order. Confirmed in prod: 36 duplicate-order clusters (56 extra rows) across multiple Pad Thai Padel venues (Bangkok AND Bali — not venue-specific), dating back to May 2026. Fix: create_pos_order_atomic() — one SECURITY DEFINER RPC (mirroring the existing record_academy_payment_in_pos_row pattern) writing the ENTIRE checkout, plus every per-order side effect, in a single transaction keyed by a client-generated idempotency key (INSERT ... ON CONFLICT DO NOTHING). A second code-review pass (round 2) then found and closed five further gaps in the fix itself: (1) a third, un-migrated order-creation path in the payment-link cron (apps/terminal/src/app/api/cron/check-payment-links) still using the old non-atomic pair-of-inserts pattern — now calls the same RPC via a deterministic `payment_link:<id>` key, which required the RPC to accept trusted service-role callers (no end-user session in a cron job) alongside authenticated staff; (2) split-payment legs offering 'Gift Card' as a method with no card-selection UI at all, so that leg's balance was never actually validated or deducted — removed from the split-payment method list rather than rushed into a half-built feature under incident-fix time pressure (full, non-split Gift Card checkout is unaffected); (3) the 'at least one payment required' validation running before the idempotency short-circuit check, which could reject a legitimate retry instead of returning the already-completed order; (4) a narrow window where the idempotency key could go out as NULL on checkout-dialog reopen, disabling protection for that one attempt; (5) the idempotency key not rotating when tip/discount/customer/tax overrides/split breakdown changed without the cart itself changing, which could silently apply stale values on a retry. See DEC-333.
supabase/migrations/20260904103554_pos_checkout_idempotency.sql — create_pos_order_atomic(): order + line items + modifiers + service charges + discounts + taxes + payment(s) + stock decrement + gift-card redemption + discount-ledger commit + drawer cash-flow + activity log, all in one transaction. Partial UNIQUE indexes on pos_orders.idempotency_key / pos_payments.idempotency_key (previously plain, non-unique). New pos_orders.stock_failures column persists a stock-decrement failure signal across an idempotent replay (a dropped-response retry no longer silently loses that warning). Per-entry BEGIN/EXCEPTION isolation on the drawer cash-flow loop (matching the stock-decrement loop) so one failing leg can't roll back a previous, already-succeeded one. auth.role() = 'service_role' bypasses the human facility-role check for trusted server-side callers; the location-belongs-to-facility tenant check applies unconditionally either way.
apps/terminal + apps/pos src/mutations/use-order-mutations.ts — call the RPC once instead of 6+ separate inserts; client-side created/_isNewOrder gating removed (the RPC now owns every side effect atomically, closing the gap where a dropped response after a real commit permanently skipped stock/gift-card/drawer/activity-log writes for a paid order).
apps/terminal + apps/pos src/components/terminal/checkout-dialog.tsx — one crypto.randomUUID() idempotency key per checkout attempt session, minted via useLayoutEffect (not useEffect, to close the null-key reopen race) keyed off cart + tip + discount + customer + service charges + gift card + tax overrides + ticket + split-payment breakdown (deliberately excluding cashTenderedCents/step — both proved to fluctuate as UI-only side effects unrelated to the RPC payload, which caused false key rotation on ordinary cash payments during testing). Gift Card removed from SPLIT_METHODS in both apps.
apps/terminal/src/app/api/cron/check-payment-links/route.ts — migrated off its own pos_orders+pos_payments insert pair (an app-level 'does source_id already exist' TOCTOU check) onto create_pos_order_atomic, keyed by payment_link:<payment_link_id>.
apps/terminal/src/lib/friendly-error.ts (+ new apps/pos/src/lib/friendly-error.ts) — reads .message off any thrown shape instead of requiring instanceof Error, so the real failure reason shows instead of '[object Object]'.
E2E: apps/terminal/e2e/checkout-idempotency.spec.ts, apps/pos/e2e/checkout-idempotency.spec.ts (new), apps/terminal/e2e/payment-link-cron-idempotency.spec.ts (new) — a forced RPC failure retried with the same key produces exactly one order; a dropped-response retry (RPC succeeds server-side, client never sees it) produces exactly one order AND exactly one of each side-effect row; a service-role caller with the payment-link cron's key pattern is accepted and idempotent under a simulated overlapping run. apps/terminal/e2e/online-payment.spec.ts fixed alongside (pre-existing test-isolation bugs unrelated to this fix, surfaced by running many specs back-to-back against the shared local facility).
DEC-333 (root cause, the atomic-RPC design, rejected alternatives — client-side created-flag gating and check-then-insert — and round-2 review findings) logged with full rationale.
A dedicated Academy → Payments destination plus the machinery behind it. An append-only package_session_transactions ledger finally answers 'where did my sessions go' — today three separate code paths destroy that history (refund NULLs lessons.player_package_id, recurring renewal zeroes sessions_used, admin adjustments discard the reason the UI collects). A unified payments feed that includes cash and QR sales, which are currently invisible because in_club/payment_link package sales write no payments row at all. Payment requests that generate a hosted checkout link on demand and still work when a facility has no payment provider configured (the state of every facility today). Reminders over e-mail (Resend edge function) or WhatsApp (click-to-send wa.me deep link, with the Meta Cloud API lane stubbed behind a provider interface). Operator-editable message templates with facility-over-system-default resolution and HTML-escaped merge tags. And billing cycles that are automatic or manual, weekly through annual, where automatic-without-a-card raises a payment request and grants sessions only once it is paid. See DEC-319 through DEC-327.
supabase/migrations/<ts>_package_session_ledger.sql — package_session_transactions (delta, balance_after, reason, lesson_id, class_id, actor_id, note, source) with staff-read/service-write RLS; consume/refund/reset rewritten to write ledger rows in the same transaction; new adjust_package_sessions RPC that persists the operator's reason; backfill from surviving lesson links with a reconciliation assertion.
supabase/migrations/<ts>_academy_payments_core.sql — payments.type widened with 'package'; payment_requests, payment_reminders, message_templates and academy_payment_settings with indexes, RLS and idempotency uniques; seeded system-default reminder templates.
supabase/migrations/<ts>_academy_billing_cycles.sql — lesson_packages.billing_mode (default 'manual', so no live club's behaviour changes on deploy) and a widened billing_period CHECK (weekly/monthly/quarterly/annual); player_packages.billing_mode, next_billing_date, billing_anchor_day; dispatch_package_billing() and dispatch_payment_reminders() hourly pg_cron dispatchers following dispatch_daily_sales_reports verbatim.
Ledger write-path: POST /api/admin/player-packages records a payments row for in_club/payment_link/credits sales and grants via RPC; PATCH switches session adjustments to adjust_package_sessions so the reason and actor are finally stored.
Deduction log on the profile: GET /api/admin/students/[userId]/package-transactions plus a shared Session activity table (date, package, signed change, balance after, reason, lesson link, staff, note) mounted in the academy student profile and a new Packages tab on the CRM customer profile, which has no package section today.
Messaging layer: apps/admin/src/lib/messaging providers (email, whatsapp-manual, whatsapp-cloud stub) mirroring lib/payments; applyMergeTags extracted to packages/common and adopted by the CRM campaign route so one implementation exists; E.164 normalisation and the wa.me builder.
Templates: /api/admin/message-templates CRUD with facility-then-system resolution, plus an Academy → Setup Payments tab with live preview, keyboard-operable merge-tag chips, reset-to-default and test send.
Reminders: supabase/functions/send-payment-reminder (requireInternalSecret, verify_jwt=false) and /api/admin/payment-requests — lazy hosted-link generation via resolvePaymentProvider, a pay-at-club fallback when payment_provider IS NULL, per-request idempotency and 24h rate limiting, failures persisted on the reminder row and shown in the UI.
supabase/functions/process-package-billing — idempotent per (player_package_id, period_start): rolls the cycle in facility-local time with month-end anchor clamping, raises the payment request, schedules reminders, and grants sessions only on payment.
Webhook settlement: Stripe and Xendit match checkout_id to a payment_request, settle idempotently, write the payments row and mark queued reminders skipped.
Payments view /{facility}/academy/payments + sidebar entry: unified feed, four summary tiles (collected, outstanding, overdue, renewals due) that render an em dash rather than 0 on failure, filters with the requestSeq stale-response guard, and row actions (send reminder, copy link, mark paid, open student) with a reminder-history drawer.
Setup surfaces: billing mode, period and anchor on the package form; next billing date on the player-packages row; academy payment settings (reminder offsets, channels, send hour, default due days, pay-at-club instructions).
0
v0.50.0Shipped
Group Business Report — multi-club consolidated email
20 Aug
One compact email for the account owners consolidating all their clubs: group totals per currency (never FX-mixed), a clubs side-by-side comparison table (collected, occupancy, played, no-show rate, new customers, with deltas), a 5-line mini-scorecard per club, and the group's already-on-the-books outlook. Configured from an owner-only card in Settings → Reports; a group subscription requires the OWNER role at every club included (RLS + API + UI, three layers). Pure reuse of facility_business_report — zero new analytics SQL. Stacked on v0.49.0 / PR #1028. See DEC-313.
supabase/migrations/20260820130354_group_report_subscriptions.sql — group_report_subscriptions (facility_ids[], per-row recipient, frequencies, send hour + explicit IANA timezone, min 2 clubs) with owner-of-every-club RLS, plus the hourly dispatch_group_reports() cron.
supabase/functions/send-group-report + _shared/group-report-html.ts — computes each club's facility_business_report over the same period (anchored to the group's timezone) and renders the compact consolidated email; per-currency hero, comparison table, minis, on-the-books. Machine-caller auth shared via _shared/send-email.ts requireMachineCaller.
API: /api/admin/group-report-subscriptions (GET filtered to fully-owned groups / POST), /[id] (PATCH validating the union of old+new club sets / DELETE), /preview — all gated by requireOwnerOfAll.
UI: Group report card in Settings → Reports (self-hides unless the caller owns 2+ clubs): list with club names, per-row preview/enable/edit/delete, create dialog with club picker (owned clubs), frequencies and send hour.
E2E tc-282 (creates a second IDR club, drives the dialog, asserts the rendered dual-currency preview, deletes) + tc-282b (anonymous 401s). tc-281 re-verified after the shared-template refactor.
2 E2E tests
v0.49.0Shipped
Club Business Report — Square-style email, per-recipient, full club
20 Aug
Automated business-intelligence email (daily/weekly/monthly) mixing POS sales with PMS metrics across 10 per-recipient sections: three-period scorecard (vs last period AND same period last year), Square-style financial summary, sales by category/area/top sellers, payment methods, court occupancy (per-court, by-hour heat strip, best/worst days), bookings behaviour (lead-time buckets, no-shows), academy (class fill rate, coach ratings), customers & loyalty (repeat %, visit-frequency histogram), top customers (masked names), and looking-ahead (next period on the books with pace). Locked down to the facility owner + an explicit two-tier allowlist (view/manage) — hidden from everyone else including other admins. Recipients, frequencies, send hour and sections managed in Admin → Settings → Reports with live preview and test send. Complements the POS-only daily sales email, which stays running untouched (the POS team is actively developing it — #1037). See DEC-311, DEC-312.
supabase/functions/send-business-report — computes the period per frequency in facility-local time, builds one HTML per distinct section set, sends via Resend; accepts preview/test_email machine calls from Admin (service-role bearer) alongside the cron's internal secret.
supabase/functions/_shared/business-report-html.ts — the single owner of the email template (Square-style summary + padel sections, email-client-safe tables, ▲/▼ deltas, per-hour occupancy heat strip).
Admin Settings → Reports (/settings/reports + sidebar entry): recipient list with frequency badges/section counts/enable switch, add-edit dialog (frequencies, send hour, 10 section toggles), live preview iframe and send-test — gated by the report-access allowlist.
API: /api/admin/report-subscriptions (GET/POST), /[id] (PATCH/DELETE, auth-first pipeline), /preview (proxies the edge function — no duplicated template in Next).
report_access table + /api/admin/report-access (+/me) — owner-managed two-tier allowlist (view/manage) gating the whole feature: RLS floor owner-only, requireReportAccess on every route, sidebar entry hidden for anyone off the list (DEC-312).
facility_business_report v2 — adds last_year comparison period, sales by area (pos_locations) and top items, occupancy by day-of-week, booking lead-time buckets, academy fill rate (classes.max_pax) + coach ratings, repeat-player % + loyalty histogram, top customers, and a looking-ahead block with same-lead-point pace.
Square-parity pass (walked against the club's real Square daily email): service charges, returns (pos_refunds), processing fees and avg tip rate in the financial card; a sales-by-hour heat strip; items-sold / top-category stat tiles; players-served; top sellers widened to 15; and the DAILY scorecard now compares against the same weekday last week (Square's 'vs Last Thursday') while weekly/monthly stay year-over-year.
2 E2E tests
v0.48.0Shipped
Academy IA — the eight-item Academy menu becomes six
21 Aug
Coaches, Classes and Packages were three of eight equal-looking entries in the Academy sidebar, even though an operator configures them once and then works in Lessons, Planning and Students every day. They are now three tabs on one new page, Academy → Setup, alongside a fourth tab holding the academy's own identity. Nothing inside those screens changed — the same tables, dialogs, toolbars and sub-tabs render unchanged, one level deeper. The old URLs still work (307 redirects that preselect the right tab), coach detail is untouched, and the merge removed three duplicate academy-bootstrap requests rather than adding any. See DEC-310.
apps/admin/src/app/[facility]/academy/setup/page.tsx + components/admin/academy-setup-page-client.tsx — new tabbed page: three clickable summary cards (Coaches / Classes / Packages) over a Coaches · Classes · Packages · Academy tab row, Coaches default. ?tab= is read server-side so the correct panel is active on first paint, and clicking a card or tab rewrites the query string via history.replaceState so refresh and bookmarks land back on the same tab without a server round trip.
Zero redesign of what the tabs contain: CoachesList and ClassesList render as-is, and the packages catalogue / player-packages sub-tabs are carried over verbatim. Moving player packages under Students is a deliberate follow-up, not part of this change.
Academy tab shows name, description, focus areas and linked-facility count as a read view; Edit opens the existing AcademySettingsDialog rather than a second copy of that form.
Summary counts reuse the list endpoints the tabs already call (/coaches, /classes, /lesson-packages) in one parallel wave — no new API route, no schema change. A failed count renders as an em dash with 'Count unavailable' instead of a plausible-looking 0.
apps/admin/src/components/admin/admin-sidebar.tsx — Academy children go 8 -> 6, ordered by frequency of touch: Overview · Lessons · Planning · Students · Reports · Setup. The Overview page's tiles now mirror that list exactly, including a Planning tile that had never been there.
/academy/coaches, /academy/classes and /academy/packages stay live as 307 redirects into the matching tab, so existing bookmarks keep working; /academy/coaches/[id] (coach detail) is deliberately untouched. Temporary rather than permanent redirects, so a browser never caches the move.
coaches-page-client.tsx, classes-page-client.tsx and packages-page-client.tsx deleted — the setup page resolves academy id, facility id and court count once and passes them down, so three duplicate GET /api/admin/academies bootstrap calls disappear instead of being multiplied by the tabs.
The coaches summary breaks down by employment type ('2 in-house · 1 freelance'), not the mockup's active/on-leave: there is no on-leave state in the schema and GET /api/admin/coaches filters is_active=true, so an active/inactive split would always have read '· 0 inactive'. That the list endpoint hides deactivated coaches entirely is a separate finding, not fixed here.
9 E2E tests
v0.47.0Shipped
Admin performance — compute next to the database, one auth verification per request
20 Aug
Nothing new on screen; the admin just stops waiting. Two structural costs were being paid on every single admin API call and neither had anything to do with the work the operator asked for. First, the serverless functions ran in Singapore while the database sits in Tokyo, so every internal hop crossed ~70ms of ocean. Second, every request proved who you were twice — middleware asked the auth server, then the route handler asked again. Steps 1 and 2 of the ladder in ADMIN-PERF-AUDIT.md; the remaining steps (per-route facility lookup, academy bootstrap waterfall, serial reports queries, client caching, the player_packages index) are still open. See DEC-309.
apps/*/vercel.json — regions pinned to hnd1 (Tokyo), beside the Supabase project. Every internal round trip drops from ~70ms to ~1-2ms; users reach the edge either way, so TTFB moves ~30ms while server work drops by hundreds of ms per page.
apps/admin/src/lib/internal-auth.ts — middleware signs a minimal { id, exp } claim (HMAC-SHA256, 60s TTL, bound to method + pathname) and hands it to the route handler, so requireFacilityAccess() no longer calls auth.getUser() a second time. Measured against the local GoTrue container: 2 round trips per admin API call become 1, across all ~116 routes, with zero route files changed.
Spoofing defence: middleware strips every inbound x-setpoint-internal-* header on every request it sees, including the paths it does not authenticate, and the matcher now lists /api/:path* unconditionally — the image-extension carve-out was extension-based, so /api/admin/lessons/<id>.png reached the real route while skipping middleware entirely.
Failure is always slow, never open: missing secret, WebCrypto fault, bad signature, wrong route, or expired claim all fall back to the original cookie/getUser() path. Nothing in the module throws.
requireFacilityAccess() no longer turns an infrastructure error into 403 — a failed user_facility_roles lookup is logged and returned as 503, so a database incident stops looking like a permissions bug.
npm run test:unit — Node's built-in runner for pure-function specs that have no UI to drive (the e2e suite stays browser-only per its own rules). First six cases cover the signed-identity contract.
STILL OPEN — 18 route files (21 call sites: crm/*, bookings, facilities, facilities/my, facility-contacts) call supabase.auth.getUser() directly instead of going through lib/auth.ts, so they keep the duplicate hop. Follow-up PR, together with merging the per-route facilities-by-slug lookup into the role query.
0
v0.46.0Shipped
Booking add-ons — camera recording added to the booking
6 Aug
Staff tick a camera recording while creating the booking; its price joins the booking total, the device is booked once the booking exists, and the recording follows the booking when it is moved or cancelled. Failures (camera offline/busy, empty court) show on the booking with a readable reason instead of only in an alert channel. See DEC-303.
apps/admin/src/lib/add-ons — create, price-by-duration, schedule/cancel/reschedule recordings, refresh results. Provider work is best-effort: a camera outage never fails the booking.
Booking dialog offers only add-ons the selected court can deliver; POST /api/admin/bookings accepts add_on_ids and each sibling court gets its own.
Booking detail lists add-ons with status, failure reason, recording link and a Remove that refunds the line.
Settings → Booking Add-ons catalogue (price per hour or per booking, camera-backed or plain).
Scheduling is an admin action with visible state: scheduled_at / last_attempt_at / attempt_count on the add-on row, the camera system's own status shown on the booking, and a Try again button on a failed recording.
booking_addon_scheduled — a fifth add-on event, emitted when the device is actually booked. booking_addon_added fires on the insert, before any camera exists, so it carries external_ref: null; the pending -> scheduled move that writes the real id used to emit nothing at all. A consumer therefore mirrored a NULL ref forever, and its own video poller skips any row without one — so with the Setpoint cron gone the recording was never fetched and the player never got it. The type is declared in TPS_WEBHOOK_SPECS and reconciled per facility via POST /api/admin/integrations/sync-webhook-events, not widened by a migration: guessing which subscriptions to widen is how two unrelated third-party endpoints ended up receiving booking_player_*.
The camera is released by the path that cancels it. OpenRally's POST /bookings/{id}/cancel and DELETE /bookings/{id}/add-ons/{id} now call the provider themselves (protocol/server/src/services/cameras.rs), and the admin routes already did — so every cancel frees the device in its own request. Credentials come from facility_integrations + Vault, the same database the protocol server already owns; nothing new is stored. Database first, provider second, and a camera that cannot be reached never fails the cancel — it leaves external_ref with a failure_reason and the booking offers Release camera.
No scheduled jobs anywhere in this feature. The 15-minute Vercel cron is gone: it was the only thing covering a cancel that arrived over the protocol, and it never ran — a player cancelled in the consumer app and the camera held the slot with failure_reason NULL and attempt_count 0, no failed release, no release attempted. Recording results now refresh when the booking is opened (GET ?refresh=1), and an add-on that never reached the camera says 'Camera was never booked' with a Try again. A sweep that has to be alive for the system to be correct is exactly how that bug survived unseen.
OpenRally 0.5.0 — POST /bookings/{id}/add-ons, DELETE /bookings/{id}/add-ons/{id}, add_ons[] on the booking read, and booking_addon_added|cancelled|ready|failed events. Additive: a consumer that sends no add_ons sees no change. There is no catalogue endpoint: a consumer names and prices what it sold, so facility_add_ons stays Setpoint's own. See DEC-304.
supabase/migrations/20260806054204_openrally_booking_add_ons.sql — or_create_booking_add_on / or_cancel_booking_add_on (service-role only) + the event trigger, so the protocol server authors no booking rows. The same migration widens every booking_created subscription with the four booking_addon_* types: fan_out_or_event drops an unlisted type with no delivery, no error and no log, so emitting and subscribing must land together.
supabase/migrations/20260807064145_cancel_booking_cancels_add_ons.sql — cancelling a booking cancels its add-ons from ANY path (the protocol's cancel is a bare UPDATE on bookings and released nothing). 'ready' rows are left alone; external_ref survives so the release job can find them. See DEC-305.
Automatic device release is scoped to add-ons Setpoint booked; one a consumer booked and never released is shown on the booking with a Release camera action instead of being cleaned up behind their back.
supabase/migrations/20260813000000_one_camera_per_booking.sql — a partial unique index stops the same booking being sold a recording twice (once here, once by a consumer), which would book one camera twice and charge the customer twice. Refused and cancelled entries are excluded, so a camera that said no can still be retried. See DEC-306.
packages/common/src/check-in.ts's derivePricing() never read bookings.payment_status — it priced the court-fee line item off payment_method alone, zeroing it for BOTH an already-paid booking AND the ordinary payment_method='pending' walk-in case. That second branch produced a live ฿0.00 subtotal on an unpaid ฿1,400 booking. derivePricing() now also accepts paymentStatus and an external-settled check (payment_method='external' + prepaid/comp), and is exported so both admin and terminal check-in callers — and terminal's own cart/ticket-restore logic — share exactly one definition. Once priced correctly, the terminal stopped excluding an unpaid court-fee item from the subtotal, discount engine, and split-bill pool: the predicate everywhere is now isBooking && isPrePaid, not a blanket !isBooking. A one-click helper in the split-bill flow pre-fills tickets from the booking's existing players, so staff don't re-enter people already on the roster. A follow-up code review caught the same predicate bug still live in cart-sidebar.tsx and ticket-modal.tsx's Request-Payment total (both missed by the original 4-file pass), a stale narrow isPrePaid check in terminal-store.ts's ticket-restore path, and a split-ticket naming bug that saved every player-count-split ticket as "Walk-in" even when the player was a known customer — all fixed and re-verified live before merge.
packages/common/src/check-in.ts — derivePricing() reads payment_status + external-settled (payment_method='external' + prepaid/comp), not payment_method alone; exported so terminal-store.ts shares it instead of re-deriving its own narrow copy.
apps/terminal — subtotal, discount engine, split-bill pool, cart-sidebar totals, and ticket-modal's pending-total/Request-Payment total all use isBooking && isPrePaid, not blanket !isBooking. Court Booking card shows the real price for any unpaid state, 'Paid'/'Online Paid' only when settled.
apps/terminal/src/components/terminal/booking-detail-sheet.tsx + bookings/page.tsx — payment_status/external_payment_method_type threaded into every check-in call site (solo quick-checkin was silently omitting both).
Split-by-player-count helper (split-bill-modal.tsx) — 'Split for this booking's N players' pre-creates + pre-selects tickets named from booking_players, reusing resolveCustomerId's existing profile lookup. Promise.allSettled + a synchronous re-entry guard so a partial failure or a fast double-tap can't orphan or duplicate tickets.
3 new E2E tests (apps/admin/e2e) — unpaid court-fee pricing + discount + pos_orders parity, external-prepaid/Marked-as-Paid settled pricing, and split-by-player-count dividing the fee 4 ways.
3 E2E tests
v0.44.1Shipped
Lesson court legs can't be rescheduled on their own (BUG)
13 Aug
A lesson keeps the same facts in two places: lessons.start_time/end_time + lesson_courts.court_id, and the linked bookings row. PATCH /api/admin/bookings cascaded cancel and no_show to the parent lesson, but let court_id / start_time / end_time be edited on a lesson leg alone — moving the booking and leaving the lesson behind. The failure is silent by construction: the calendar renders the BOOKING, while coach availability reads the LESSON (available-slots' coachBusy check, OpenRally's coach_availability handler). A stale lesson row therefore kept consuming the coach's window while the operator looked straight at that window and saw it free. Reported as a coach missing from the consumer app: Jose Mouliaa's only bookable hour on 18 Aug (11:00–12:00) was being eaten by a lesson whose booking actually sat at 08:00 on another court. 17 pairs at Pad Thai Padel had drifted since 10 Jun; the 5 future ones were repaired in prod on 13 Aug. See DEC-291.
apps/admin/src/app/api/admin/bookings/route.ts — PATCH returns 409 (with lesson_id + a pointer to the lesson route) when a reschedule field targets a lesson leg. Checked before the status cascades, so a combined status+reschedule request is refused rather than having the move quietly dropped. Rejects instead of mirroring the cascade: the lesson route already owns num_courts, per-court conflicts, the coach-reservation gate and the override toggle, and a second copy of that ruleset is what produced the drift.
apps/admin/src/components/admin/lesson-form-{dialog,fields}.tsx — the slot grid labelled every unavailable time "outside coach hours", including times inside the coach's own hours, so the dialog contradicted its own "Coach available 11:00 AM–12:00 PM" banner. Those now read "coach not free" and explain the real reason (another lesson, or the reserved court is booked). Same override gate, different wording.
packages/common/src/coach-availability.ts — toMinutes exported alongside fromMinutes, so callers rendering availability windows can also test a slot against them.
apps/admin/src/app/api/v1/facilities/[slug]/coaches/[id]/available-slots — new coach_busy[] (the coach's own lessons that day, facility-local, clipped to the date). Without it a caller cannot tell the one unavailability the override CANNOT clear (the coach is teaching — create-single-lesson.ts 409s before it reads availability_override) from the one it can (their reserved court is taken). The form now suppresses the override toggle in the first case instead of walking the operator into that 409.
scripts/repair-lesson-booking-desync.sql — prints the desynced rows, realigns lessons to their bookings in one transaction, prints them again. Sets availability_override on the repaired rows: the coach-reservation triggers reject an off-schedule lesson without it, and that flag is exactly the record of an operator-forced move. Executed against prod on 13 Aug (5 rows); kept as a one-off record, with the five conditions that would break a re-run documented in its header.
v0.44.0Shipped
Court-booking roster — two-way consumer sync
10 Aug
A court booking's roster had no way in or out of Setpoint. booking_players carries no trigger, and booking_modified fires only on time, court, price and payment — so a consumer that mirrored the booking saw the person it was created under and never learned about anyone added afterwards, for the whole life of the booking. There was no inbound path either: the protocol touched booking_players only inside the match-seat handler. Now a roster change travels both ways. booking_player_joined / booking_player_left announce it outbound, skipping match and lesson bookings because those mirror their own rosters onto the same table and would otherwise emit the same person twice by two routes. Two new protocol endpoints let a consumer seat and unseat players by email. Both the admin API and the protocol go through shared SQL functions, so the organizer hand-over and the cost-split recompute have one definition instead of one per caller — a booking's split cannot be right at the front desk and wrong from the app. See DEC-290.
supabase/migrations/20260810075109_emit_booking_player_events.sql — booking_player_joined / _left, guarded to skip match + lesson bookings (both mirror their roster onto booking_players). Widens existing webhook_subscriptions in the same migration: fan_out drops an unlisted type with no error and no log.
supabase/migrations/20260810080557_booking_player_roster_rpcs.sql — add_booking_player / remove_booking_player own the organizer link, the hand-over when the organizer leaves, and the cost-split recompute. EXECUTE revoked from PUBLIC: they are SECURITY DEFINER and would otherwise let any signed-in user edit any facility's roster.
POST /openrally/v1/booking-players and /booking-players/remove-by-guest — seat and unseat by email, the way a consumer knows its players. Idempotent per booking and player; refuses to empty a booking, which is a cancellation.
The admin players route now calls the shared functions instead of restating them (−130 lines).
protocol/spec/openapi.yaml — Booking Roster tag, both paths, four schemas; protocol version 0.4.0 → 0.5.0. GET /openrally/v1/ advertised 0.1.0 through four releases and now reports the real version.
v0.43.0Shipped
Court camera status
6 Aug
Settings → Courts shows whether each court's recording camera is up, read live from the club's camera system on page view (nothing stored, no cron). Staff attach a device to a court from the Edit Court dialog; a 60s per-facility cache keeps repeat views off the provider, and a refresh button bypasses it. Each club connects its own provider account in Settings → Integrations (password in Vault). Read-only in v1 — pricing, scheduling and selling stay where they are. See DEC-286, DEC-287.
apps/admin/src/lib/cameras — CameraProvider interface + Clutch adapter (token cache, 5s timeout, one 401 retry), 60s per-facility status cache.
GET /api/admin/facilities/[facilityId]/camera-status (?refresh=1 bypasses cache; 503 when the provider isn't configured).
PUT/DELETE /api/admin/courts/[id]/camera — attach, re-point or detach a court's device (owner/admin only).
Courts list Camera column (Available / Unavailable / Unknown + checked-at + refresh) and Recording Device ID field in the court form.
supabase/migrations/20260806015742_facility_integrations.sql — per-facility provider connection; password in Vault via two service-role-only SECURITY DEFINER functions, dropped with the row. See DEC-287.
Settings → Integrations → Recording Provider: each club connects its own camera account (write-only password, Test connection). Facility connection wins; env is a fallback for facilities without one.
1 E2E tests
v0.42.0Shipped
Court covering — indoor / outdoor / roofed
5 Aug
Setpoint modelled a court's covering as the boolean courts.is_indoor, so a club with roofed (covered but open-sided) courts had nowhere to record it — WareHaus typed 'Roofed' into the free-text surface_type field instead, where it renders as a surface and reaches no consumer as covering. New courts.covering column ('indoor' | 'outdoor' | 'roofed') is now the source of truth, exposed on the OpenRally resource response and mapped into the consumer app's court type at provisioning. is_indoor is not dropped: a BEFORE INSERT OR UPDATE trigger keeps the pair in lockstep (covering wins on conflict), so all 138 existing is_indoor references — mostly e2e fixtures — keep working unchanged and the two columns cannot drift. Roofed courts report indoor=true, so no existing OpenRally consumer sees a behaviour change. Syncing surface_type into the consumer's court type was rejected: it is unvalidated free text whose production values include 'AG', 'hard_court', and 'Artificial Grass', which would render as 'Ag' and 'Hard_court' and would overwrite the indoor/outdoor signal players actually use. See DEC-285.
supabase/migrations/20260805035055_court_covering.sql — additive covering column with CHECK (indoor|outdoor|roofed), backfill from is_indoor, and the courts_sync_covering_is_indoor trigger reconciling both directions.
apps/admin court form: the two-state Indoor switch becomes a 3-value Covering select; the courts list Type badge renders covering. Both admin court routes accept and validate it.
protocol/server: ResourceResponse gains covering (additive — indoor retained for consumers that predate it). openapi.yaml Resource schema updated and protocol version bumped 0.3.0 → 0.4.0.
Provisioning payload carries covering; the consumer edge function maps it to the club court type, falling back to is_indoor for callers that predate the field.
E2E: apps/admin/e2e/tc-265-court-covering-trigger.e2e.ts (trigger lockstep, both directions) + tc-034 updated to drive the new select and assert the Roofed badge.
DEC-285 logged with rejected alternatives (surface_type sync; a generated is_indoor column, which Postgres cannot convert an existing column into and which would break every writer that sets it).
1 E2E tests
v0.41.0Shipped
Cross-window court pricing (hybrid)
31 Jul
Bookings that cross peak/off-peak pricing windows now sum each segment’s hourly rate (e.g. 10:00–12:00 = 800+500=1300) instead of charging the full-duration price of the start window only. Single-window bookings keep DEC-273 exact 60/90/120 lookup so fixed-mode non-linear prices stay intact. Implemented in calculate_member_price — admin, web, OpenRally, and TPS inherit the fix with no caller changes. See DEC-281.
supabase/migrations/20260731032100_cross_window_pricing.sql — hybrid list-price in calculate_member_price: in-window exact duration match; spanning bookings split at window boundaries and sum ROUND(seg_min/60 × 60-min row).
supabase/migrations/20260929080113_fix_cross_window_pricing_same_rate_windows.sql — adjacent segments with an identical rate card are merged before cross-window proration (WareHaus: same slot priced 1,800 vs 2,400 by start time). DEC-451; tests TC-295 (8) + TC-296 (6).
Resend-powered email notifications for all match result events — submitted, confirmed, auto-confirmed, disputed, admin resolved. Supabase Edge Function + pg_net for auto-confirm emails.
send-match-email Edge Function — handles all 5 event types via Resend API
pg_net integration — auto-confirm cron triggers email via net.http_post()
Result submitted email — notifies all participants except submitter
Result confirmed email — notifies all participants (manual + auto-24h)
Result disputed email — notifies all participants except disputer
Admin resolved email — notifies all participants after dispute resolution
Second demo facility — seed data for local testing (Downtown Tennis Club, Austin TX)
Hardcoded reference cleanup — remove all sunset-padel hardcoding from app source
E2E setup script fix — direct DB access for Supabase CLI v2 compatibility
8 E2E tests
v0.16.0Shipped
Booking/Match Restructure & Private Matches
12 Mar
Clean architecture: booking = foundation (court, time, price), match = optional game layer. open_matches → matches rename, public/private visibility, simplified match creation, atomic booking+match creation from calendar.
Coach/lesson visibility in all booking views (6 admin + 2 web spots), web lesson booking flow rewrite (coach-first with class selection), new public API endpoint for coach classes.
Web: Coach-first lesson booking flow — pick coach, then class selection (auto-select if single class)
Web: StepLessonClass component — class cards with name/type/duration/price, replaces manual pickers
API: GET /api/v1/facilities/[slug]/coaches/[id]/classes — public endpoint for coach's active classes
API: Booking enrichment (enrich-bookings.ts) — LEFT JOIN lesson_courts → lessons → coaches/classes on 4 routes
Admin: Calendar day/week views show graduation cap icon + coach name for lesson bookings
Admin: Booking detail dialog — Lesson Details section (coach, class name, type badge)
Admin: Bookings list + customer history — new Coach column
Fixes BUG-019 (open since 2026-04-01): the Stripe membership webhook handler silently returned 200 when its user_memberships INSERT hit the partial unique index idx_user_memberships_active_unique (one active row per user+facility), leaving users who had an orphaned active row — from the previous Stripe account or from admin manual-assign — paid but without a membership.
Handler: error-check every .insert()/.update()/.rpc() in apps/admin/src/app/api/v1/webhooks/stripe/route.ts; return 500 with structured log on unexpected errors so Stripe retries and failures surface in Vercel + Stripe Dashboard
Handler: orphan-adoption branch for membership_subscription — existing active row with NULL stripe_subscription_id is UPDATEd in place rather than duplicated; different non-null sub_id 500s as an anomaly
Handler: invoice.paid, invoice.payment_failed, customer.subscription.deleted, lesson_package_recurring, booking_payment branches all adopt the same loud-fail pattern (no more swallowed errors)
Admin-initiated payment collection across three surfaces: court bookings, lessons, memberships. Two buttons on each surface — Get Payment Link (copy to clipboard) and Send via Email (inline recipient confirmation, Resend delivery). Stateless Stripe Checkout Sessions; no schema changes. Closes the gap where admin-assigned memberships had no in-product way to collect payment (BUG-019 descendant case).
API: POST /api/admin/bookings/[id]/payment-link — refactored to export shared createBookingCheckoutSession helper
API: POST /api/admin/bookings/[id]/payment-link/email — resolves guest_email or auth email, invokes edge function
API: POST /api/admin/lessons/[id]/payment-link — per-player Stripe Session, auto-resolves single-player lessons
API: POST /api/admin/lessons/[id]/payment-link/email — same with lesson_players.email fallback to auth email
API: POST /api/admin/user-memberships/[id]/payment-link — subscription Checkout, lazy Stripe Product/Price on plan
API: POST /api/admin/user-memberships/[id]/payment-link/email
API: POST /api/admin/memberships/payment-link — Plans-tab flow, body { user_id, plan_id, facility_id }
API: POST /api/admin/memberships/payment-link/email
Shared: ensureMembershipPlanStripePrice() + resolveUserEmail() helpers under /api/admin/_lib/
Webhook: new lesson_payment branch in /api/v1/webhooks/stripe — marks lesson_players paid
Edge functions: send-booking-payment-link, send-lesson-payment-link, send-membership-payment-link (Resend, HTML + text)
UI: PaymentLinkActions component — Copy button + Send via Email with inline recipient confirmation
UI: wired into booking-detail-dialog, lessons-list row dropdown (with multi-player picker), user-memberships-list row dropdown, membership-plans-list with typeahead
Stripe payment-link redirect to public web (BUG-038)
29 Apr
Resolves BUG-038. Admin-generated Stripe payment links are emailed to customers who don't have admin accounts — after paying, the post-Stripe redirect now lands on the public web app instead of the admin domain (which auth-walled them at /auth/login). Memberships land on /{slug}/memberships?payment=success (unprotected). Bookings and lessons land on /{slug} (facility home, unprotected) because the web middleware also auth-walls /bookings and /academy/lessons — landing at the home avoids the same dead-end on the public side. Stripe webhook (server-to-server) was always correct; only the browser hand-off was broken.
API: bookings/[id]/payment-link — success_url/cancel_url to ${webAppUrl}/{slug}
API: lessons/[id]/payment-link — success_url/cancel_url to ${webAppUrl}/{slug}
API: memberships/payment-link (Plans tab) — success_url/cancel_url to ${webAppUrl}/{slug}/memberships
API: user-memberships/[id]/payment-link — success_url/cancel_url to ${webAppUrl}/{slug}/memberships
Env: webAppUrl resolved via WEB_APP_URL || NEXT_PUBLIC_WEB_APP_URL (both already in env.local.example / env.production.example)
E2E: tc-110 case 3 reads post-Stripe URL before closing tab and asserts WEB_URL prefix + no 'Sign in' text
Refines the BUG-038 fix from v0.21.3. Instead of redirecting to the public web app — which isn't yet positioned as the customer destination — Stripe now redirects to a dedicated /payment-success landing page on the admin domain, allowlisted from auth middleware. The page renders a clean confirmation card showing what was bought (plan / court+date / lesson+date), the amount, and a Stripe-receipt note. Display data is passed entirely via URL query params, so the page is fully static (no DB lookup, no auth required).
Page: apps/admin/src/app/payment-success/page.tsx — server component, branches by ?type=membership|booking|lesson, ?status=success|cancelled
Middleware: /payment-success allowlisted alongside /auth and /api/v1
API: bookings/[id]/payment-link — success_url to /payment-success?type=booking with court/date/duration/amount
API: lessons/[id]/payment-link — success_url to /payment-success?type=lesson with lesson/date/duration/amount
API: memberships/payment-link (Plans tab) — success_url to /payment-success?type=membership with plan/amount
API: user-memberships/[id]/payment-link — success_url to /payment-success?type=membership with plan/amount
E2E: tc-110 case 3 asserts ${ADMIN_URL}/payment-success URL prefix + 'Payment confirmed' heading + plan name visible
Docs: known-issues BUG-038 resolvedIn updated to reference the v0.21.4 page
0
v0.22.0Shipped
Unified Booking Calendar
6 Apr
Cal.com-inspired month calendar replaces date strips across both apps. Courts, lessons, and joinable group classes shown in a single unified slot list. Academy page becomes informational. Admin calendar gets month picker and lesson creation from the calendar grid.
API: GET /api/v1/facilities/{slug}/availability/range — date-range availability for calendar dots
Web: Month calendar with availability dots (enhanced calendar.tsx component)
Web: useAvailabilityRange + useUnifiedSlots hooks for merged court/lesson/join slots
Web: Unified booking-flow.tsx — court/lesson/join-lesson branching with LessonBookingData
Web: Remove booking page tabs — single calendar entry point
Web: Move lesson step components (review-pay, verify, confirmed) from academy/ to booking/
Web: Delete 12 academy booking components (-3,831 lines)
Web: Redesign academy page — informational with ClassPass-inspired layout, 'Book a Lesson' CTA
Web: Redesign coach profile — informational only, classes read-only, book link to homepage
Admin: Month calendar popover on day + week views for date jumping
Admin: Extract LessonFormFields reusable component from lesson-form-dialog
Admin: Create booking dialog Court/Lesson type toggle with inline lesson form
Admin: Remove 'Schedule Lesson' from lessons page — all scheduling via calendar
Shared: DateAvailability + DateRangeAvailability types in @setpoint/common
Admin: Complete multi-court lesson support — class form, availability APIs, pricing fix, PATCH court editing, calendar display
E2E: Update TC-001/005/006/007, add TC-010–013 for unified calendar flows
8 E2E tests
v0.24.0Shipped
Centralized Loyalty & Discount Engine
6 May
Engine + admin only. New crm_customer_groups + crm_customer_group_members + crm_discount_rules + crm_discount_rule_targets + crm_discount_applications tables centralize predefined groups (CRM-managed, e.g. 'VIP — 10% off bookings') alongside auto-mirrored subscription-plan rules and credit-tier rules. resolve_discounts(p_facility_id, p_profile_id, p_lines, p_channel, p_at) is a STABLE/read-only function that returns the per-line discount breakdown given a list of cart lines; commit_discount_applications(p_applications) is the idempotent writer that posts to crm_discount_applications. The admin booking flow (/api/admin/bookings) resolves + commits post-insert (the discount itself is applied earlier inside calculate_member_price, which now delegates to resolve_discounts) and serves as the canonical consumer pattern. Two auto-mirror triggers keep crm_discount_rules in sync with membership_plans + credit_tier_configs INSERT/UPDATE/DELETE so existing loyalty configuration flows through the new engine without a migration of UI surfaces. Stackability is permissive (per-rule flag, default false, 100% cap). Polymorphic actor_id validated by trigger (no FK). pos_orders.discount_kind + discount_note + pos_order_items.is_foc + foc_reason columns ship as part of the locked-in contract for the POS/Terminal consumer to write to. POS + terminal consumer wiring is delegated to the POS/Terminal dev as a follow-up (BUG-082) — they consume the same engine via the contract and mirror the pattern from the admin booking route. See plan-pos-terminal-discount-integration.md for the handoff. Lesson + program consumer wiring deferred (BUG-074) — those flows still bill list price; integrating the resolver requires pushing it INTO create_lesson_atomic so the ledger and charge stay in sync. M5 OpenRally surface deferred (BUG-073). Web app inherits via existing /api/v1 admin endpoints, no apps/web changes.
Migration: supabase/migrations/20260506094046_centralized_discount_engine.sql — 5 new tables + resolve_discounts + commit_discount_applications + crm_discount_rules_validate_actor + auto-mirror triggers (membership_plans, credit_tier_configs) + idempotency unique index (rule_id, document_type, document_id, COALESCE(line_ref,'')) + ALTER pos_orders ADD discount_kind (CHECK preset|cashier_adhoc|group_variation) + discount_note + ALTER pos_order_items ADD is_foc + foc_reason + backfill from existing membership_plans + credit_tier_configs
Eval harness: supabase/snippets/discount_engine_evals.sql — 17 cells (E1–E17) pinning resolver semantics: actor validation, rule selection, value_kind percent/fixed, scope_domain booking/lesson/program/pos, target_type any/court/sport/category/item/lesson/program, stackability cap (E5), additive stack (E4/E6/E7), scope-domain filter, time-window starts_at/ends_at (E9), conditions.min_subtotal (E10), polymorphic actor validation (E11), idempotency (E12), read-only invariant (E13), calculate_member_price delegation (E14), Deliverable B columns (E15), program scope reserved (E16), and multi-group parallel-array correctness (E17 — pins the bug fix found during PR self-review where DISTINCT on a constant kind tag desynced the parallel arrays). Wrapped in BEGIN/ROLLBACK; green after migration.
Admin API: 7 new routes under /api/admin/crm/ — groups (list/create/get/update/delete), groups/[id]/members (add/remove), discount-rules (list/create/get/update/delete with shared validateRuleBody), discount-rules/preview (resolver dry-run), discount-rules/[id]/applications (ledger view), customers/[id]/discounts (per-customer panel data: groups + applied discounts). Plus /api/admin/customers/search (typeahead) for adding members to groups
Admin UI: sidebar gets two new CRM children — Groups + Discount rules. /[facility]/crm/groups list with create dialog. /[facility]/crm/groups/[id] detail with members + group-scoped rules. /[facility]/crm/discount-rules list grouped by actor (subscription / credit tier / customer group). discount-rule-editor.tsx (~330 lines) with adaptive scope target picker (Any / specific Court / Sport / Category / Item / Lesson / Program). customer-discount-panel.tsx slot rendered in the customer-detail overview tab — shows the customer's groups + applied discounts ledger.
Admin booking route: /api/admin/bookings resolves + commits post-insert via the centralized engine. Guard mirrors the price-cascade guard (!price_override && !price_amount) so the ledger only records discounts on bookings whose price actually went through calculate_member_price (which itself delegates to resolve_discounts). Best-effort: failures are logged via console.error but do not roll back the booking. Lesson route wiring deferred to a follow-up PR (BUG-074) that pushes resolve_discounts INTO create_lesson_atomic — committing applications post-hoc would write a misleading ledger because effectivePrice = price_per_pax_amount_override ?? cls.price_per_pax_amount, never reduced by the resolver.
POS + terminal consumer wiring delegated (BUG-082) — handed off to the POS/Terminal dev so ownership stays with the author. Foundation shipped here: pos_orders.discount_kind (CHECK preset|cashier_adhoc|group_variation) + discount_note + pos_order_items.is_foc + foc_reason columns are the locked-in contract; @setpoint/common/discounts exposes the typed RPC wrappers. The dev mirrors the admin booking route pattern (apps/admin/src/app/api/admin/bookings/route.ts ~line 264+) for their checkout flow. Full handoff in apps/docs/src/data/plan-pos-terminal-discount-integration.md (contract + pattern + integration steps + test seam).
calculate_member_price() refactor: now delegates to resolve_discounts internally (single source of truth) while preserving the DEC-183 better-of arm (subscription vs credit-tier GREATEST). Public RPC signature unchanged — consumers see the same JSON shape.
E2E: apps/admin/e2e/tc-130-discount-engine-end-to-end.e2e.ts — full end-to-end via SQL + admin API + RPC. Creates own facility/court/customer/pricing-rule in beforeAll; creates a customer group via admin API, adds a customer, creates a 10% off booking rule, asserts calculate_member_price RPC returns 90000 (10% off ฿1000), inserts a booking via /api/admin/bookings, asserts bookings.price_amount = 90000 + crm_discount_applications has the row + customer-detail /discounts endpoint returns it. Cleans up everything in afterAll. TC-130 added to test-cases.ts (7 steps).
DEC-186 logged with full alternatives + rationale + deferred items (M5 OpenRally / programs / matches surface wiring / legacy POS retirement / Deliverable B cashier discount UI polish).
1 E2E tests
v0.32.0Shipped
Multi-leveling — multiple skill-level systems per player, club-configurable
2 Jun
Setpoint can hold and display several skill-level systems for the same player per sport (Setpoint Elo + external Connected-app / WPR / DUPR / NTRP), with each club choosing which to enable per sport. Per the CEO: leveling lives in TPS (the consumer app) — Setpoint does NOT compute, migrate, or admin-edit external levels; it PULLS them via OpenRally and shows them read-only. Consumer-agnostic (CLAUDE.md #1): a generic level_type='consumer' carries the value and the provider identity lives in player_ratings.source (a neutral consumer namespace) — the literal 'tps' appears nowhere in setpoint/. Additive + reversible only (prod LIVE): one new CHECK value + one RPC + one additive OpenRally endpoint; the existing UNIQUE key is untouched, no backfill. New SECURITY DEFINER RPC or_upsert_player_level(email, facility_id, sport, level, level_type, source) email-matches a customer (reusing the booking reverse-write pattern) and upserts read-only, blocking internal level_types from consumer ingest. New additive OpenRally endpoint POST /openrally/v1/players/by-email/level-ingest (server 0.1.0→0.2.0, idempotent, API-key auth; facility from the key, never the body). Club config in facilities.settings.enabled_level_systems + a new admin Leveling settings tab; GET ratings filters external systems by it (internal self_assessed/admin_override/coach_set always shown). Admin customer page shows external levels read-only (lock icon). Fixed two adjacent latent bugs the feature surfaced: the ratings API facility_slug/facility_id mismatch and the facilities PATCH that clobbered settings (now merges). DUPR/WPR live third-party API sync is a later PR (needs partner API access); their toggles ship now. Companion padel-backend PR (feature/openrally-level-push) pushes the level in the BACKGROUND (EdgeRuntime.waitUntil, off the match-scoring path) so a slow/down OpenRally never delays certification — this is OpenRally's two-way 'consumers PROVIDE a player attribute' direction (push), NOT a synchronous PMS live-pull (debated + rejected: OpenRally can't fetch from a consumer + untestable + couples uptime). DEC-258.
supabase/migrations/20260602065007_add_multi_leveling_consumer_level_type_and_upsert_rpc.sql — add 'consumer' to player_ratings.level_type CHECK (additive); new SECURITY DEFINER RPC or_upsert_player_level (email-match + read-only upsert on the EXISTING UNIQUE key; blocks internal level_types; service_role only).
protocol/server: new handlers/player_ratings.rs (ingest) + db/player_levels.rs (RPC call) + models/player_level.rs; route POST /openrally/v1/players/by-email/level-ingest; Cargo 0.1.0→0.2.0. Facility from the API key; idempotent; minimal response (no internal IDs).
protocol/server/tests/player_level_ingest.rs — 7-case integration test (happy path, in-place update, idempotency replay, unknown-email 404, blocked internal type, missing idempotency key, no ID leak). Full cargo suite green (1 pre-existing unrelated or_sports flake).
apps/admin/src/app/api/admin/players/[userId]/ratings/route.ts — GET filters by facilities.settings.enabled_level_systems (internal types always shown); accepts facility_slug OR facility_id (fixes the page-breaking param mismatch); PUT stays internal admin_override only.
apps/admin/src/app/api/admin/facilities/route.ts — PATCH now MERGES the settings jsonb instead of overwriting (was silently wiping sport_types / pay_at_club_enabled).
packages/common/src/leveling.ts (new) — EDITABLE/READONLY level-type sets, isEditableLevelType, levelSystemLabel (consumer-agnostic: 'consumer' label derived from source), filterRatingsByEnabled. Regenerated packages/database/src/database.types.ts (level_type union now includes 'consumer').
apps/admin/src/components/admin/customer-ratings.tsx — levels grouped by system; external levels render read-only (lock icon, no pencil); internal stay editable. New apps/admin/src/components/admin/leveling-systems-tab.tsx wired into the settings page (per-sport on/off switches).
padel-backend (separate PR feature/openrally-level-push): supabase/functions/src/services/openRallyLevelSyncService.ts + a BACKGROUND call in matchRepository.certifyScores (EdgeRuntime.waitUntil, off the match-scoring path) — pushes the new level to OpenRally clubs by email (source='tps', non-throwing, 5s-timeout-guarded). A slow/down OpenRally never delays certification. The ONLY place 'tps' appears.
apps/admin/src/app/[facility]/crm/customers/[id]/page.tsx — render <CustomerRatings> in a Skill Levels card. The component was previously ORPHANED (its only host, the unmounted CustomerDetailPanel), so levels were invisible in admin — this restores the operator-facing display the manager flagged as 'working previously'.
supabase/migrations/20260602060020_fix_orphaned_facility_customer_role_trigger.sql — drop the orphaned trg_facility_customer_on_role trigger + function left behind by the facility_customers removal (20260528114200); it broke EVERY user_facility_roles insert (facility-role grants + `supabase db reset` seeding) on a clean rebuild. Surfaced by the tc-249 e2e; idempotent + prod-safe.
E2E: apps/admin/e2e/tc-249-multi-leveling-display.e2e.ts — 7/7 passing locally: API filtering by club config, slug/id resolution, PUT internal-only, external level read-only on the customer page, internal level editable, and the Leveling settings tab toggling a system off.
Coaching Feedback — AI ingestion container (ALB-5)
7 Jul
Setpoint-admin-side data model and ingestion API for the external OmniGPT YouTube-transcription coaching agent (built separately — no AI/video-analysis work here). Phase 1 of 2: this covers Setpoint storage + Admin UI only; the Staff PWA (apps/calendar_pwa) coach-facing trigger/integration is ALB-8, a separate follow-up once this is live. Two new nullable columns on lesson_players (feedback_generated_by 'ai'|'coach', feedback_reviewed_at) let the admin UI badge AI-drafted text and gate what's considered 'reviewed.' Two new server-to-server endpoints (GET roster, PATCH ai-feedback) authenticate via the existing hashed, per-facility api_keys system (one real key per club, previously-unused infrastructure — see DEC-266) and let the agent map its analysis to real players and write draft feedback/rating/attendance, via a single atomic DB function (apply_ai_feedback_to_lesson_player) that enforces the coach-overwrite protection and the confirmed-status check inside the UPDATE itself, closing a check-then-act race an app-layer check would have had. The admin roster panel gained a second internal step (Review & Save) that shows every confirmed player's final feedback together before it's considered final, reusing the existing step-based single-Dialog pattern from merge-customer-dialog.tsx, and confirms via one batched request instead of one per player. A new 'Coaching' tab on the Customer 360 page shows a player's finalized feedback history (coach-written rows always; AI-drafted rows only once reviewed) — and the pre-existing OpenRally/TPS broadcast trigger on lesson_players now holds back unreviewed AI drafts the same way, only emitting once a coach has run Review & Save. An 8-angle code review before merge caught the auth/broadcast/race issues; DEC-262/263/264/265/266.
supabase/migrations/20260706094102_add_ai_feedback_tracking_to_lesson_players.sql — additive: feedback_generated_by text default 'coach' + CHECK IN ('ai','coach'), feedback_reviewed_at timestamptz default null. Verified via supabase db reset (columns + constraint confirmed in psql).
supabase/migrations/20260707043836_ai_feedback_ownership_guards_and_openrally_review_gate.sql — apply_ai_feedback_to_lesson_player(...) RETURNS SETOF lesson_players: one atomic UPDATE...WHERE with the coach-overwrite protection (now covers coach_rating, not just coach_feedback text) and status='confirmed' check baked into the WHERE clause, so a concurrent coach edit or a since-cancelled player can't slip through. Also redefines emit_group_lesson_player_event to hold back session_feedback_updated while feedback_generated_by='ai' AND feedback_reviewed_at IS NULL, emitting on the null→set transition instead. DEC-266.
GET /api/admin/lessons/[id]/roster + PATCH /api/admin/lessons/[id]/ai-feedback (new) — auth via validateApiKey (apps/admin/src/app/api/v1/_lib/api-key.ts, the existing hashed per-facility api_keys system, previously unused by any route) with an explicit facility_id match against the lesson; ai-feedback calls the new atomic RPC per player, 207 partial-success shape for mixed-validity batches. DEC-266 (supersedes the static-secret ai-feedback-auth.ts from DEC-265, removed).
src/middleware.ts — extended the existing external-auth allowlist (previously one literal webhook path) with a regex-pattern list to also bypass the staff-session check for the two new agent endpoints, matching the established Resend-webhook bypass pattern. DEC-265.
apps/admin/src/app/api/admin/lessons/[id]/players/route.ts (PATCH) — editing coach_feedback/coach_rating now also sets feedback_generated_by='coach', so a coach's edit to an AI draft can't be silently clobbered by a later AI run. New mark_reviewed flag stamps feedback_reviewed_at; new player_ids batch path lets Review & Save confirm every player in one request instead of one-per-player. DEC-263.
apps/admin/src/components/admin/lesson-roster-panel.tsx — AI-drafted badge on prefilled feedback (now requires non-empty coach_feedback, not just feedback_generated_by='ai', so an attendance/rating-only AI write doesn't show a misleading empty-draft badge); new step state ('roster'|'review') adds a Review & Save screen listing every confirmed player's final feedback together, confirming stamps feedback_reviewed_at for all of them in one batched call. DEC-262.
apps/admin/src/app/api/admin/lessons/route.ts + .../lessons/[id]/route.ts — both player-list endpoints (list view and detail view) updated to project the two new fields; the list endpoint was the one actually feeding the roster panel's initial render, and was caught missing them by e2e TC-253 before the badge would have worked in real usage.
New apps/admin/src/components/admin/customer-coaching-history.tsx + apps/admin/src/app/api/admin/crm/customers/[id]/coaching/route.ts — 'Coaching' tab on the Customer 360 page, reverse-chronological finalized feedback history. Reuses the query shape the Staff PWA follow-up (ALB-8/Phase 2) will need. DEC-264.
E2E: apps/admin/e2e/tc-253-ai-coaching-feedback.e2e.ts — API auth/write/protection incl. cross-facility-key rejection (403), rating-only overwrite protection, stale-cancelled-player rejection, OpenRally review-gating (draft doesn't broadcast, review does) + UI badge + batched Review & Save flow stamping feedback_reviewed_at. 4/4 passing locally against local Supabase.
packages/database/src/database.types.ts — manually patched with just the 2 new lesson_players fields (Row/Insert/Update) rather than a full regen, to avoid bundling ~280 lines of pre-existing unrelated schema drift into this PR.
8-angle/10-candidate code review run before merge per the mandatory /code-review gate (correctness×3, reuse, simplification, efficiency, altitude, conventions, plus 1-vote verification on every candidate) — found and this PR fixed: no facility scoping on the AI-agent auth, premature OpenRally broadcast of unreviewed drafts, rating-only overwrite gap, check-then-act race, missing confirmed-status check, misleading empty AI-drafted badge, and the N+1 Review & Save request pattern.
DEC-262/263/264/265/266 logged with rejected alternatives.
Two-round calendar UI/UX pass driven by live operator feedback (Alba). Round 1: moved the date label + its prev/next arrows to read as one cluster on the left instead of split across the header; added a nullable guest_booking_category column (special_guest/event) settable on the guest/internal booking panel at creation, plus a Calendar/Court Booking/Events/Coaching filter dropdown; added bulk-cancel for recurring series (cancel just one, all, or a chosen subset) using the existing [recurring:<uuid>] notes tag as the series key; added a horizontal/vertical layout toggle to Day view, reusing the same slot-lookup helpers so both orientations always agree on availability; fixed the date-picker popover's own prev/next-month arrows being dead (Calendar UI component silently overwrote its internal navigation state whenever a `month` prop was passed without `onMonthChange`). Round 2, after live testing surfaced real gaps: the category dropdown only existed at creation time, so already-created bookings (including ones that predate the column) had no way to be tagged or retagged — added the same Select to Edit; recurring bookings had no visible indicator on the calendar, and the status badge silently rendered as a blank pill when there was nothing to show — added a hover-tooltip icon for recurring bookings and stopped rendering the badge when its label is empty; the category filter dropdown sat next to the page title competing with page identity — moved it into each view's own toolbar row, before the date-nav arrows, via a new shared CalendarCategoryFilterSelect component.
apps/admin/src/components/admin/calendar-grid.tsx, week-calendar-grid.tsx, month-calendar-grid.tsx — date label + prev/next arrows grouped as one cluster on the left; category filter dropdown moved out of the page title row into each view's own toolbar, before the date-nav arrows.
supabase/migrations/20260713121134_add_guest_booking_category.sql — nullable guest_booking_category text column + CHECK IN ('special_guest','event') on bookings.
apps/admin/src/components/admin/create-booking-dialog.tsx + edit-booking-dialog.tsx — category Select (Special Guest/Event/None) at creation AND via Edit; apps/admin/src/app/api/admin/bookings/route.ts — POST + PATCH validate and persist guest_booking_category (credit-paid RPC paths apply it via a logged follow-up update since the RPC signature is fixed).
New apps/admin/src/lib/booking-category-filter.ts + calendar-category-filter-select.tsx — shared filter predicate + shared dropdown component reused by all three calendar views and calendar-page-client.tsx.
New apps/admin/src/app/api/admin/bookings/recurring-series/route.ts + booking-detail-dialog.tsx bulk-cancel scope chooser (just this one / all in series / choose which) — bulk paths reuse the existing single-booking cancel endpoint sequentially so refund/lesson-cascade logic isn't duplicated.
apps/admin/src/components/admin/calendar-grid.tsx — horizontal/vertical Day view toggle; both orientations call the same getBookingsForCell/isSlotOccupied/isCourtAvailable/handleBookingClick helpers.
apps/admin/src/components/ui/calendar.tsx callers — date-picker popover now tracks its own browsed month (datePickerMonth) separately from the selected date/week/month and wires onMonthChange, fixing the popover's own prev/next-month arrows.
Recurring-series hover indicator (Repeat icon + Tooltip, 'Part of a recurring series') on booking cards where recurringGroupId is set, in both Day-view orientations and Week view; status badge no longer renders empty for a confirmed, not-checked-in booking.
apps/admin/src/components/admin/bookings-list.tsx — strips the [recurring:<uuid>] notes tag so it doesn't leak into the visible Notes field on the Bookings search page (a third BookingDetailDialog caller the fix had initially missed).
E2E: apps/admin/e2e/tc-254-calendar-ux-fixes-round2.e2e.ts — edit-time category change reflected by the Events/Court Booking filter, recurring indicator tooltip, blank-badge fix, header layout order. NOT yet run — local Supabase was unavailable in the dev environment (Colima/Docker mount error) for the whole of this session; needs a real run before being trusted. Round 1 (date-nav, category-at-creation, bulk-cancel, orientation toggle) shipped without dedicated new E2E coverage of its own — code-reviewed and manually verified live in the browser instead.
apps/docs/src/data/ac-calendar-ux-fixes-round2.ts — AC + implementation plan for round 2 (deleted after this PR merges per convention).
A club asked for court pricing that isn't proportional to duration (e.g. 60min=1200, 90min=1400, 120min=1800 — not 1.5x/2x the hourly rate), which the existing pricing engine couldn't represent since calculate_member_price always prorated a single rule's price by a duration ratio computed at read time. Simplified the whole model instead of patching read-time proration: every pricing rule is now always exactly 3 materialized rows (60/90/120 min — the only durations any booking flow offers), tied together by a new pricing_rule_group_id. pricing_mode ('auto' | 'fixed') is now purely a write-time/UI concern — 'auto' means one hourly price is entered and the other two durations (and member price) are computed and stored proportionally, recomputed on every edit; 'fixed' means each duration's price is entered and stored independently. calculate_member_price was simplified back to its pre-feature shape plus one filter (exact duration_minutes match) — no proration math, no mode branching, no tie-break logic at read time. A one-time migration backfilled every existing club's pricing into this shape using the same ratio formula the old runtime proration used, verified price-identical for every current booking. Sets up (but does not yet implement) the OpenRally/TPS side reading the same explicit rows directly — that Rust-side work is paused pending a conversation with the TPS team. Two rounds of code review (8 angles each) caught and fixed real bugs along the way — see DEC-273 for the full history, including an earlier read-time-proration version of this feature that shipped and was replaced.
supabase/migrations/20260717080000_add_pricing_mode_to_pricing_rules.sql — pricing_rule_group_id uuid NOT NULL DEFAULT gen_random_uuid() added to pricing_rules; a scope-grouped backfill materializes every existing club's pricing into exactly {60,90,120} rows per scope (price-identical to prior runtime proration, verified against seed data including a pre-existing duplicate-scope edge case); calculate_member_price simplified to an exact duration_minutes match with priority DESC, no proration/mode logic at read time. Adds a unique index on (facility_id, court_id, sport_type, day_of_week, start_time, end_time, priority, is_holiday, duration_minutes) and an index covering duration_minutes on the pricing cascade's hot path.
apps/admin/src/app/api/admin/pricing-rules/route.ts + [id]/route.ts + new _lib.ts — POST/PATCH/DELETE operate on the group (3 rows) as one unit; 'auto' payload is a single anchor price, 'fixed' payload requires all 3 durations' prices explicitly (no gaps possible); PATCH replaces the group's rows atomically via a new replace_pricing_rule_group RPC instead of delete-then-insert.
apps/admin/src/components/admin/pricing-rule-dialog.tsx — Auto-calculate / Fixed price per duration toggle; Auto mode unchanged (one price + member price); Fixed mode shows all 3 durations' price + member price inputs at once, all required.
apps/admin/src/components/admin/pricing-rules-list.tsx — one row per group, compact per-duration price breakdown, Auto/Fixed badge.
packages/common/src/pricing.ts — new BOOKABLE_DURATIONS = [60,90,120] constant, the single source of truth for the bookable-duration set. apps/admin/src/app/api/admin/bookings/route.ts, bookings/price/route.ts, and v1/pricing/calculate/route.ts now reject any duration_minutes outside this set with a 400 before calling calculate_member_price, closing the path where an unmaterialized duration (found during review: apps/web's public booking flow and the public API docs both still offered/documented 120/180 instead of 90/120) silently priced a booking at 0. apps/web/src/components/booking/step-select.tsx and apps/apidocs/src/data/api-reference.ts updated to the correct 60/90/120 set.
First code-review round found 10 issues, all fixed: 90-min availability wrongly hidden on the public site (60-min stride bug), backfill anchor-selection + orphaned legacy-duration rows, silently-swallowed courts-name lookup errors, price_amount:0 comp bookings wrongly hitting the duration whitelist, silent Price Preview failures, missing DB index + uniqueness constraint, and the duration whitelist duplicated across 3 files (now the shared BOOKABLE_DURATIONS helper above).
apps/admin/e2e/tc-035-pricing-rules.e2e.ts and tc-076-pricing-rules-e2e.e2e.ts updated for the materialized 3-row shape: fixtures now create rules through the same shape the API requires (tc-076), a member-pricing test rescoped to avoid colliding with the new uniqueness constraint (tc-035), plus new coverage for 90-min scaling and 180-min rejection.
Known gap (deferred): protocol/server (OpenRally, used by TPS bookings) has its own independent pricing implementation (materializer.rs find_price, holds.rs) that always linearly prorates from a 30-min base and was not updated to read the explicit per-duration rows. Verified this change doesn't worsen the gap for any current (proportional) club — paused pending TPS team input before restructuring OpenRally's hold-time pricing.
Verified locally: supabase db reset applied cleanly; backfill manually re-verified against seeded data (including the duplicate-scope edge case) for byte-identical prices; calculate_member_price scenarios (auto, fixed, non-bookable duration, priority cascade) all correct; TypeScript typecheck clean across apps/admin, apps/web, apps/docs, apps/apidocs, packages/common.
2 E2E tests
v0.34.1Shipped
Security fix — enforce one academy per facility (ALB-33)
17 Jul
academy_facilities had no uniqueness constraint, letting one academy link to more than one facility. Since every admin/API route uses a service-role client that bypasses RLS entirely, any query that resolved a facility's academy_id(s) and filtered a facility-scoped table (lessons, classes, lesson_packages) by academy_id alone -- without also filtering by facility_id -- could leak another facility's data past requireFacilityAccess(). Found during ALB-22 code review; a follow-up audit confirmed it was exploitable in 4 endpoints, including a public, unauthenticated one (lessons/available). Fixed with a UNIQUE(academy_id) constraint on academy_facilities, closing the entire bug class structurally (all 4 endpoints + any not-yet-found ones) rather than patching each query site. Also closed a related gap: the academy-facility linking route only checked the caller's access to the academy's home facility, never the target facility being linked.
supabase/migrations/20260717063517_add_academy_facilities_unique_academy_id.sql — UNIQUE(academy_id) constraint, with a pre-flight check that names the exact offending academy_id(s) in the error if applied against data with existing duplicates.
apps/admin/src/app/api/admin/academies/[id]/facilities/route.ts — friendly 409 on constraint violation instead of a raw 500; POST now also requires requireFacilityAccess on the target facility_id, not just the academy's home facility.
E2E: apps/admin/e2e/tc-260-academy-facility-unique-constraint.e2e.ts — proves a duplicate link attempt is rejected with a 409 and no second row is created.
1 E2E tests
v0.35.0Shipped
Academy Students tab — customer↔student relation, private/group lesson history (ALB-22)
17 Jul
New Students tab under Academy showing a customer's coaching/academy relationship in isolation, plus a private/group completed-lesson breakdown surfaced both there and on the Customer 360 Coaching tab. The customer↔student relationship is modeled as a read-only academy_students SQL view (DEC-274) — not a synced table with triggers — since a student has no independent business state and is fully derivable from lesson_players (booked/attended a lesson) and player_packages (bought an academy package). One shared helper, getStudentLessonBreakdown, computes the private/group completed-lesson split scoped by BOTH academy_id and facility_id (academy_facilities is many-to-many, so academy_id alone could leak a sibling facility's lessons past the requireFacilityAccess boundary), used by both the Student profile page and the extended Coaching-tab endpoint so the two surfaces can never diverge. A student belonging to more than one academy linked to the same facility is deduplicated to one row/one combined breakdown rather than rendering twice.
supabase/migrations/20260717050723_add_academy_students_view.sql — new read-only academy_students view, UNION of lesson_players and player_packages grouped by (academy_id, user_id). Additive only, no backfill, no trigger.
apps/admin/src/lib/academy-student-lessons.ts — getStudentLessonBreakdown(admin, {userId, academyId, facilityId}) + shared resolveCustomerDisplayNames helper, single source of truth for private/group completed-lesson counts and entries.
New apps/admin/src/app/api/admin/students/route.ts (list, dedup'd by user_id) + students/[userId]/route.ts (detail, aggregates across every academy linked to the facility the student belongs to).
apps/admin/src/app/api/admin/crm/customers/[id]/coaching/route.ts — extended with a lessonBreakdown field (same shared helper), additive to the existing feedback-history response.
New Academy → Students list (students-list.tsx) and student detail page (student-detail-client.tsx, Private/Group tabs) + admin-sidebar.tsx and academy-page-client.tsx entries.
apps/admin/src/components/admin/customer-coaching-history.tsx — private/group lesson-count block added above the existing feedback history on the Customer 360 Coaching tab.
E2E: apps/admin/e2e/tc-255-academy-students-tab.e2e.ts — TC-255..259: Students list counts, Student profile private/group sections, Customer-page/Student-page count parity, non-student exclusion, package-only student with 0/0 counts.
DEC-274 (academy_students modeled as a view, not a synced table) logged with rejected alternatives.
5 E2E tests
v0.36.0Shipped
Academy Students v2 — recency filter, per-coach breakdown, packages tab, pagination (ALB-36)
21 Jul
Follow-up to v0.35.0 after live use surfaced it was too simplistic. The Students list now defaults to only customers with a completed lesson in the last 3 months (adjustable via a date-range filter — 3mo/6mo/1yr/all-time) rather than every historical student, split into two tabs: All Students (recency-gated population) and Students with Packages (anyone who's ever purchased a package at this facility, independent of recent activity). Each student shows a per-coach breakdown ("Maria Garcia (4), John Lee (3)"), not just a private/group total, plus their full package history with an active/exhausted/expired/cancelled status badge reusing the Player Packages tab's existing badge component/colors (extracted to a shared apps/admin/src/lib/package-status.ts). Added a search bar (debounced) and a coach filter on the list, plus infinite-scroll pagination. Lesson counts/coach breakdown shown per student are deliberately ALL-TIME regardless of which tab or date filter is active — only population membership (who appears in the list) is recency-gated — so the Students list, Student detail page, and Customer 360 Coaching tab can never show divergent counts for the same person, preserving the single-source-of-truth invariant from v0.35.0/DEC-274. Also added a search bar to the existing Player Packages tab (Academy → Packages), which had none.
apps/admin/src/lib/package-status.ts (new) — computePackageStatus/STATUS_COLORS extracted from player-packages-list.tsx, shared by both the Player Packages tab and the Students list.
apps/admin/src/app/api/admin/students/route.ts — rewritten: tab=active|packages, months (3/6/12/0=all-time) recency filter on population only, search (name/email, escaped for PostgREST .or() metacharacters), coach_id filter, offset/limit pagination with has_more/total. Coach-breakdown/package counts reuse resolveCustomerDisplayNames from academy-student-lessons.ts and are always all-time.
apps/admin/src/components/admin/students-list.tsx — rewritten: All Students / Students with Packages tabs, search (debounced 300ms) + date-range + coach filters, infinite-scroll via IntersectionObserver with a request-generation guard against stale-page races, per-coach breakdown chips, package status badges.
apps/admin/src/components/admin/player-packages-list.tsx — refactored onto the shared package-status helper (no behavior change); added a name/email search bar.
E2E: apps/admin/e2e/tc-261-students-v2-filters-pagination.e2e.ts — TC-261: 3-month default + per-coach breakdown, all-time filter, coach filter, search, Students-with-Packages tab + status badges; TC-261f: pagination offset/limit/has_more; TC-261g: Player Packages search bar. apps/admin/e2e/tc-255-academy-students-tab.e2e.ts updated for the new "All Students" recency semantics (a package-only student with no lessons no longer appears there by design).
Follow-up to the 2026-07-22 incident (PR #878/#879: Terminal/POS customer search leaked every facility's customers to staff with roles at multiple facilities, fixed via a new search_facility_customers RPC). Alba asked for a full audit of everywhere else the same class of bug could exist. Root cause found: the original schema's "Facility staff can read all customers" RLS policy granted ANY user with role='owner' at ANY single facility unconditional SELECT on the entire customers table — no facility check on the returned row at all — while a separate, correctly-scoped policy already existed for coach/staff/admin/manager but 'owner' was never added to it. Since RLS policies OR together, that one policy alone was enough to leak everything to every owner account regardless of how carefully other code was scoped, and it doesn't even apply to apps/admin (its service-role client bypasses RLS entirely, so those routes needed their own explicit scoping regardless). Fixed at the root (dropped the global policy, added 'owner' to the scoped one, extended it to also cover user_memberships + crm_customer_directory) and then swept all 4 apps for the same pattern: 9 unscoped endpoints in admin (customer/player search, player ratings/card, CRM customer detail view+edit, CRM segment preview/members, loyalty wallet lookup, and a CRM Groups 'add every customer on the platform' button), the POS/Terminal single-customer detail page (worse than the original bug — no multi-facility role needed, just a UUID in the URL — plus a write-authorization gap on the same customer-update actions and two lower-severity group-membership leaks), and 7 unscoped queries in the Staff PWA (2 live search boxes plus a QR check-in screen that named a customer with zero relationship to the scanned facility). A few genuinely intentional global-directory lookups were left alone for consistency (exact-known-email lookup to attach an existing account to a brand-new booking/guest checkout, matching find-or-create-guest.ts's established pattern) — see DEC-279 and DEC-280 for the reasoning and the one place that reasoning was reversed (customers/search, previously left global for the same reason, is now facility-scoped since a broad partial-match browse is a materially different risk than an exact-match lookup).
supabase/migrations/20260728070631_fix_owner_global_customer_read_rls.sql — root-cause RLS fix on customers: dropped the unconditional owner-role global-read policy, added 'owner' to the properly facility-scoped policy alongside coach/staff/admin/manager, extended its coverage to user_memberships + crm_customer_directory.
supabase/migrations/20260728072623_add_get_facility_customer_rpc.sql — get_facility_customer (single-record facility-scoped lookup for POS/Terminal's customer detail page) and admin_search_facility_customers (service-role variant of search_facility_customers for the admin backend, which has no user JWT for the RPC's own internal auth_check to key off of).
apps/pos + apps/terminal use-customers.ts useCustomer() — was a raw `.from("customers").eq("id", profileId)`, no facility check at all; the customer detail page at /customers/[id] leaked any facility's customer PII to anyone who knew/guessed a UUID. Now calls get_facility_customer.
apps/admin crm/groups/[id]/members/route.ts add_all — was 'add every customer from the customers table'; now scoped to customers with an actual relationship (booking/order/ticket/membership) to the group's facility.
apps/admin crm/customers/[id]/route.ts GET+PATCH, players/[userId]/ratings, players/[userId]/card, credit-tiers/user-tier — each fetched/wrote a customer profile by a raw URL/query param id with no facility check; now gated by isTiedToFacility (extended with the pos_orders relationship it was missing).
apps/admin customers/search + players/search (typeahead mode) + crm/segments' shared loadEnrichedCustomers helper — were unscoped platform-wide scans/searches; now scoped via admin_search_facility_customers or getFacilityCustomerIds.
apps/pos + apps/terminal use-customer-groups.ts — group-membership queries now inner-join crm_customer_groups filtered by facility_id instead of trusting a client-supplied group_id/profile_id unscoped.
apps/terminal update-customer.ts + update-facility-customer.ts server actions — had zero facility check on the write side (could overwrite another facility's customer's name/email/phone by id); new shared isTiedToFacility helper (includes user_facility_roles so a staff member can still always edit their own profile) gates both, with facilityId threaded through from all 3 call sites.
apps/calendar_pwa create_booking_sheet.dart + book_coaching_sheet.dart — the two live customer-search autocompletes were unscoped `.from('customers')` partial-match browses; now call search_facility_customers.
apps/calendar_pwa qr_checkin_screen.dart — a scanned QR code for a customer with no session at the scanned facility today still displayed that customer's real name in the "not found" message; now only surfaces the name once a facility-scoped lesson match is confirmed.
DEC-279 (documents leaving the intentional-global-directory pattern alone for customer/coach-add flows, after checking precedent against crm/groups and classes/route.ts) and DEC-280 (documents where that reasoning was reversed for this fix, and the exact-email-lookup exceptions that were deliberately kept) logged.
0
v0.37.0Shipped
Academy Groups — level-based training cohorts (ALB-38)
22 Jul
Fills a gap explicitly called out as deferred/unsolved in GROUP-COACHING-PLAN.md: no persistent 'training group' concept existed — programs (clinics/leagues) are time-bounded with no level field; classes/lessons/lesson_players track one scheduled instance's roster, not a standing cohort across many sessions. New third Students tab: staff create named groups (level label, optional coach), assign/remove players (many-to-many — a player can belong to more than one group, since they can be assigned to different group coachings), and see each member's package + sessions done/remaining (reusing the existing package-status logic from v0.36.0, not a new per-group counting mechanism, since membership isn't exclusive). An 'Unassigned Students' panel surfaces active students in zero groups with a one-click add — the thing that makes this an operational tool, not just a roster viewer. v1 is deliberately roster-only: no link to a specific recurring class/schedule, no auto-booking, and level is a staff judgment call (player_ratings shown only as an optional reference), matching this repo's own deferral of scheduling automation. New tables academy_groups + academy_group_members, RLS mirroring the coaches table convention. First code-review pass found and fixed: a level-reference tie-break that didn't actually use recency as its own comment claimed, a raw Postgres FK-violation error leaking to the client instead of a clean 400, and 3 duplicated displayName() implementations (extracted to lib/display-name.ts). Second, deeper pass (8-angle finder + verify) before deploy found and fixed: coach_id on group create/update now validated against the group's academy (previously any coach_id passed FK-existence only); archiving a group now correctly frees its members back into Unassigned (exclude_grouped join was ignoring is_active, orphaning members of an archived group); Unassigned panel now paginates instead of silently capping at 50; the Students↔Groups tab switch no longer resets the roster panel's search/coach/months filters (both panels stay mounted, toggled via CSS, instead of unmount/remount); a 4th displayName() duplicate in the Add Player dialog; group creation's academy pick is now deterministic (was an unordered .limit(1) on the many-to-many academy_facilities join); the 5 new academy-groups routes now use withFacilityAccess (404-masking) instead of hand-rolled requireFacilityAccess (403), closing a wrong-facility probing leak; and all 13 new (supabase as any) casts removed (CLAUDE.md bans new ones — the diff already ships proper generated types for both new tables). Deliberately left as-is, matching established codebase precedent (crm/groups/[id]/members allows the same, including an add_all-every-customer option; classes/route.ts accepts coach_id with the same FK-only check): adding any customer to a group regardless of prior facility relationship, since customers is an intentional global directory — the security boundary here is staff-facility access, not customer-facility history. Also surfaced (not fixed in this PR — flagged to Alba): /api/admin/customers/search has no facility scoping at all, a pre-existing gap now exposed by a second consumer (this tab's 'Add Player' search) in addition to the existing Player Packages 'Assign Package' dialog.
New apps/admin/src/app/api/admin/academy-groups/ route tree — list+create, get+update+delete, add/remove member. Reuses resolveCustomerDisplayNames (academy-student-lessons.ts) and computePackageStatus (package-status.ts) rather than reimplementing coach-name/package-status logic.
apps/admin/src/app/api/admin/students/route.ts — added exclude_grouped=true param (scoped to the current facility's groups only) powering the Unassigned panel.
New apps/admin/src/components/admin/academy-groups-panel.tsx (groups grid, create dialog, Unassigned quick-add) + group-detail-client.tsx (roster: add via search, remove, edit group) + apps/admin/src/app/[facility]/academy/students/groups/[id]/page.tsx.
apps/admin/src/components/admin/students-list.tsx restructured into a thin 3-tab router; existing roster logic extracted unchanged into students-roster-panel.tsx.
New apps/admin/src/lib/display-name.ts — shared displayName() helper, replacing 3 duplicated copies introduced across this PR's new files.
E2E: apps/admin/e2e/tc-262-academy-groups.e2e.ts — create group, quick-add from Unassigned, add via search dialog, package/sessions shown correctly, remove member, removed member reappears in Unassigned.
DEC-277 (persistent group as new schema, not a Programs extension) and DEC-278 (many-to-many membership, level as staff judgment, roster-only v1) logged with rejected alternatives.
1 E2E tests
v0.38.0Shipped
Group lessons — coachless creation actually works + recurring UI matches Court pattern
24 Jul
A prior PR made coach_id nullable on lessons and labeled the field '(optional — assign later)' for group classes, but Alba caught live that it didn't actually work — the Create Lesson button stayed disabled, and the recurring toggle was still the old single-end-date model, not the Start/End Date + day-of-week-pills + live-preview UI she'd asked for to match the existing Court-booking recurring UI. Root cause of the button-disabled bug: the entire Available Time Slots section (and therefore court/time selection, and therefore submittability) was hard-gated on `coachId && date`, so a coachless group lesson had no way to pick a time — the earlier 'optional' label was cosmetic only. Fixed with a new coach-independent slot endpoint plus a Select 'No coach — assign later' option (the class's own default coach was auto-filled with no way to clear it, since coachId="" can't be a Radix SelectItem value). Recurring UI rebuilt to match the Court pattern exactly by extracting the Court booking dialog's date-math into a shared packages/common/src/recurrence.ts module and rewriting the backend contract from repeat_until to {start_date,end_date,days_of_week}. An 8-angle code review before merge caught three further bugs in the recurring backend itself (not caught by manual testing since they only trigger on specific inputs): a midnight-straddling lesson would get an end time before its start on every generated occurrence (re-derived end-time-of-day was applied to the wrong date instead of adding duration to start); the UI's own stated max end date (start+90 days, every day of week) generated 91 occurrences against a 90-occurrence server cap, so the boundary the UI advertised as the max always got rejected; and the new coachless-slots endpoint silently swallowed courts/bookings query errors, which — for the bookings query specifically — could report an already-booked court as available. All three fixed before this PR, plus a leftover (supabase as any) blanket cast narrowed to match the sibling endpoint's per-call convention. Verified end-to-end in a live local browser session (not just code-reviewed): coachless group lesson created successfully with an amber 'Unassigned' badge on the list, and a 5-occurrence weekly recurring series created exactly as previewed.
New apps/admin/src/app/api/v1/facilities/[slug]/classes/[classId]/available-slots/route.ts — coach-independent slot lookup for group classes (court operating hours + booking conflicts only, no coach schedule). lesson-form-dialog.tsx + lesson-form-fields.tsx branch their slot-fetch useEffect on coach presence: coach picked → existing coach-scoped endpoint; no coach + group class → this new endpoint.
apps/admin/src/components/admin/lesson-form-dialog.tsx + lesson-form-fields.tsx — Coach Select gains a '__unassigned__' sentinel SelectItem ('No coach — assign later', shown for group classes only) since a class with its own default coach auto-fills coachId with no prior way to clear it back to empty.
apps/admin/src/components/admin/create-booking-dialog.tsx — its Lesson-tab submit handler always POSTed to /api/admin/lessons regardless of data.is_recurring (a genuine oversight in the prior PR — this is a third entry point rendering LessonFormFields that the recurring-endpoint routing fix had missed); now branches to /api/admin/lessons/recurring when is_recurring is set.
New packages/common/src/recurrence.ts (exported via packages/common/package.json's "./recurrence") — computeOccurrenceDates/addDaysToISODate/dayOfWeekFromISODate/formatShortDate/RECURRENCE_SOFT_CAP_DAYS extracted verbatim from create-booking-dialog.tsx's previously-local Court recurring UI, now shared by both lesson forms so preview counts and actual generated counts can't drift.
apps/admin/src/components/admin/lesson-form-dialog.tsx + lesson-form-fields.tsx — recurring UI rebuilt to match create-booking-dialog.tsx's Court pattern pixel-for-pixel: Start Date/End Date (90 days max) + Mon–Sun day-of-week pills + live 'N lessons will be created: date1, date2, ...' preview, replacing the old single-toggle + single-end-date design.
apps/admin/src/app/api/admin/lessons/recurring/route.ts — rewritten contract from repeat_until (single date, weekly-only) to recurrence:{start_date,end_date,days_of_week}, using the shared computeOccurrenceDates. Code-review fixes applied before merge: end time now derived as occStart + duration_minutes instead of re-deriving end_time's own time-of-day per occurrence date (fixes silent end-before-start corruption for any lesson whose start/end straddle midnight); added a DEC-197-style day-range hard cap (rangeDays > RECURRENCE_SOFT_CAP_DAYS, mirroring bookings/route.ts exactly) and removed the separate 90-occurrence-count cap it was colliding with (the day-range cap alone now bounds both elapsed time and occurrence count, closing an off-by-one where the UI's own max end date could produce 91 occurrences against a 90 cap); added days_of_week integer/0-6/no-duplicate validation matching the sibling court-booking endpoint's.
apps/admin/src/app/api/v1/facilities/[slug]/classes/[classId]/available-slots/route.ts — code-review fixes: courts and bookings queries now check `error` and return a 500 instead of silently falling through to an empty array (the bookings case specifically could otherwise report an already-booked court as available, risking a double-booking); narrowed the blanket `createAdminClient() as any` to per-call casts matching the sibling coach-scoped endpoint's convention (CLAUDE.md bans new blanket casts).
Verified live in a local browser session (Playwright auth-cookie session, not e2e automation): coachless group lesson — Available Times renders and a slot/court can be picked with no coach selected, Schedule Lesson button genuinely enables, submission succeeds and the lessons list shows an amber 'Unassigned' badge; recurring series — 5 weekly occurrences created exactly matching the live preview text, with correct per-occurrence start/end times.
No new E2E coverage added this PR — verification was manual/live-browser only, per the pre-existing note that local OTP/PKCE login is broken in this dev environment; a Playwright auth-cookie session was reused instead. Should get dedicated E2E coverage in a follow-up.
The Academy Student page was a 164-line stub (name + a flat private/group lesson list) even though the richer data — join date, level, packages, sessions remaining, lifetime spend, group membership, coaching feedback, notes/tags — already existed in the schema and was already wired into the CRM Customer 360 page; Academy staff just never saw it because reception/coaches work in Academy, not CRM. Rebuilt around five tabs (Overview, Packages, Lessons, Groups, Notes & tags), reusing the existing CRM notes/tags endpoints and the CustomerCoachingHistory component rather than duplicating storage — a student IS a customer (same id), so the CRM endpoint already had everything except group membership, upcoming lessons, and a lifetime-spend total, all added as small extensions to GET /api/admin/students/[userId]. Also wires up the cross-page navigation requested: a group chip links to that group's page, a group roster row links back to the student's profile, and an upcoming-lesson card deep-links into the Lessons list with that exact lesson's edit dialog already open (?open=<lessonId>). Along the way, found and fixed a real pre-existing production bug: crm_notes' author name was fetched via a PostgREST embedded join (customers!crm_notes_author_id_fkey) that can never resolve — that FK actually points to auth.users, not public.customers — so the CRM Notes feature has silently shown zero notes for every customer since it was written; fixed via a separate batched author-name lookup (resolveCustomerDisplayNames), the same pattern already used for coach names elsewhere. Terminal/POS have the identical broken join in their own note-fetching hooks — not fixed here (separate apps, separate deploy lifecycles), flagged as a follow-up. 8-angle code review before merge caught and fixed: a pre-existing e2e test (tc-255/TC-256) asserting the old Private/Group tab structure, silently-swallowed mutation failures on add/remove tag and add/remove group, a deep-link fetch with no error handling and no facility-match check, nondeterministic 'primary group' ordering (no ORDER BY on the reverse lookup), an ambiguous 'current package' fallback that could show an expired/cancelled package with no status qualifier, a 404 for group members with no lesson/package history yet (now falls back to group membership before declaring not-found), dead code, and 7 new (supabase as any) casts that turned out to be unnecessary entirely (removed all of them, typecheck stayed clean).
apps/admin/src/app/api/admin/students/[userId]/route.ts — extended with fetchStudentGroups (reverse lookup via academy_group_members, deterministic order by joined_at), fetchUpcomingLessons (scheduled lessons at/after now, ascending, flags recurring_group_id), and fetchPackageSummary (full purchase history + lifetime spend, summed from lesson_packages.price_amount for payment_status='paid' purchases only). 404 fallback added: a group member with no lesson/package history yet (so no academy_students row) now still resolves via their group membership instead of 404ing.
apps/admin/src/app/api/admin/crm/customers/[id]/route.ts — fixed the crm_notes author-name bug described above; notes query drops the broken embedded join and resolves author_id via resolveCustomerDisplayNames instead.
apps/admin/src/components/admin/student-detail-client.tsx — full rewrite: Overview (stat cards, upcoming-lesson strip, recent lessons), Packages (purchase history table with sessions-used progress), Lessons (mounts CustomerCoachingHistory), Groups (membership cards linking to the group page, add/remove), Notes & tags (composer + thread, tag picker). All five mutation handlers (note, tag add/remove, group add/remove) now toast on failure instead of silently no-oping.
apps/admin/src/components/admin/group-detail-client.tsx — each member row's name/avatar now links to /{facilitySlug}/academy/students/{user_id}.
apps/admin/src/components/admin/lessons-list.tsx — new ?open=<lessonId> deep-link: fetches that lesson directly and opens its edit dialog on mount, independent of the paginated/filtered table state; checks the fetched lesson's facility_id against the page's own facilityId before opening (defense-in-depth — the underlying GET route only authorizes against the lesson's own facility) and toasts on any failure (deleted/cancelled lesson, network error) instead of silently doing nothing.
apps/admin/e2e/tc-255-academy-students-tab.e2e.ts — TC-256 updated for the new tab structure (Overview's merged recent-lessons list + the Lessons tab's shared coaching-breakdown testids with Customer 360) instead of the removed Private/Group tabs. Re-ran the full 6-test file live: all pass.
8-angle code review (correctness×3, reuse, simplification, efficiency, altitude, conventions) run before merge — confirmed findings fixed as described above; duplication between this page's tag/note UI and Customer 360's (Reuse angle) and some efficiency nice-to-haves (sequential fetch stages, no cross-navigation caching of the facility-wide tags/groups lists) were assessed and deliberately deferred rather than risking a larger refactor of the 1329-line CRM page under time pressure.
6 E2E tests
v0.31.0Shipped
Coach availability — blocks_courts toggle (strict / open mode)
27 May
Pad Thai Padel CEO + manager asked for a per-availability toggle: strict (court is held for the coach — current behavior, default) or open (court stays bookable by customers, other coaches' lessons, and TPS users; if someone takes it before the coach has a lesson booked, the coach loses the slot). The boss's rationale was avoiding empty hours at small clubs — if no coaching gets booked AND nobody else can book the court either, the slot is lost twice. Scope LOCKED to a single boolean (DEC-246) — auto-fallback to another free court, duration limits, calendar drag-drop, and the recurrence-UX rewrite the CEO also asked for are explicitly deferred. coach_availabilities gains blocks_courts boolean NOT NULL DEFAULT true so every existing row preserves pre-v0.31.0 behavior. check_booking_against_coach_reservations's court_booking branch filters AND ca.blocks_courts = true so open rows no longer block customer bookings or other coaches' lessons. The lesson_court branch primary aggregate stays mode-agnostic (DEC-247, load-bearing SQL comment + TC-243 regression test) — a coach's lesson must still lie inside their stated window — but gains a new DEC-248 displacement guard: if a booking has already taken the court at the lesson's time under open mode, raise lesson_displaced_by_booking with displaced_by_booking_id + displaced_by_type in DETAIL JSON so the admin UI can render a human-readable cause via translate-coach-error.ts. coach_blocked_intervals_for_facility (read-side mirror, the SQL function the OpenRally Rust materializer queries) filters strict-only too — no Rust code change needed, just the SQL update. A new trigger coach_availabilities_invalidate_or_cache fires AFTER INSERT/UPDATE/DELETE: DELETEs affected or_materialized_slots rows AND pg_notify('coach_availability_changed', facility_id) so a Rust LISTEN handler in protocol/server invalidates the L1 availability cache prefix — closes the 60-second stale-blocked window between admin flipping a slot to open and TPS users seeing it as bookable. coach_availabilities_check_overlap_trigger relaxes (DEC-254): only strict×strict overlap on the same court is rejected; open×anything is allowed. Backstop trigger + route-level pre-check (coach_availability_booking_conflicts) skip open proposed rows — no point flagging conflicts on a row that doesn't claim the court. Admin coach-schedule-editor gains a per-availability toggle (DEC-252: unchecked = strict default; opt-in to open) with mode-aware inline copy. Calendar grid renders open-mode tiles with dashed border + 60% opacity + 'Open' badge so operators never confuse a non-blocking row with a strict 'Reserved' tile (DEC-250). PUT /api/admin/coaches/[id]/schedule returns 422 with code exception_on_open_availability when the proposed exception targets only open availabilities (DEC-253). OpenRally protocol response stays consumer-agnostic — no new field on slots (DEC-255). 11 decisions logged (DEC-245..251).
supabase/migrations/20260527141545_coach_availability_blocks_courts.sql — ALTER TABLE coach_availabilities ADD COLUMN blocks_courts boolean NOT NULL DEFAULT true; DROP+CREATE check_booking_against_coach_reservations with new p_excluded_booking_id parameter, court_booking branch filters strict, lesson_court branch keeps mode-agnostic primary + new DEC-248 displacement guard; lesson_courts + lessons trigger functions pass booking_id; backstop trigger + coach_availability_booking_conflicts skip open rows; coach_availabilities_check_overlap relaxes to strict×strict only; coach_blocked_intervals_for_facility filters blocks_courts=true; new coach_availabilities_invalidate_or_cache trigger DELETEs from or_materialized_slots + pg_notify('coach_availability_changed', facility_id).
supabase/migrations/20260527143142_coach_schedule_replace_blocks_courts.sql — CREATE OR REPLACE coach_schedule_replace to read blocks_courts from each availability JSON entry (COALESCE default true preserves pre-v0.31.0 callers).
protocol/server/src/cache/listen.rs (new) + main.rs spawn — LISTEN coach_availability_changed; invalidate L1 availability cache prefix `availability:{facility_id}:` on each notification. Auto-reconnects with 5s backoff. cargo build clean.
packages/common/src/coach-availability.ts — CoachAvailabilityRow gains blocks_courts?: boolean with load-bearing comment that program / lesson-package validators MUST NOT filter on it (DEC-251: the toggle is about court reservation, not coach presence).
apps/admin/src/components/admin/coach-schedule-editor.tsx — AvailabilityEntry gains blocks_courts; addEntry defaults true (DEC-252 polarity); normalizeEntry coalesces to true; AvailabilityRow gets a checkbox toggle (data-testid availability-row-{i}-blocks-courts-toggle) with mode-aware inline copy; ExceptionRow accepts availabilities and greys per-court chips for courts with no strict availability (DEC-253).
apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts — GET projects blocks_courts; IncomingAvailability accepts optional blocks_courts; coalesceRows uses it in the grouping key (open + strict rows with the same time stay distinct entries); expandedAvailabilities passes it to coach_schedule_replace; new validateNoExceptionOnOpenOnly returns 422 with code exception_on_open_availability when an exception targets only open availabilities (AC-19, by court_id intersection).
apps/admin/src/components/admin/calendar-grid.tsx — CoachReservation gains blocks_courts; fetchCoachReservations projects it; render branches on isOpen for extra classes (opacity-60 border-2 border-dashed), data-mode attribute (E2E targeting), aria-label, tooltip, and a new 'Open' badge in the top-right of the start cell (DEC-250).
apps/admin/src/lib/translate-coach-error.ts — new case for lesson_displaced_by_booking. Reads displaced_by_type from DETAIL JSON and surfaces 'Court was taken by [a customer booking | another coach's lesson]' instead of a generic SQL error (DEC-248).
E2E: apps/admin/e2e/tc-242-coach-availability-open-mode-allows-customer-booking.e2e.ts — open availability does NOT block a customer court booking on the same court (headline feature).
E2E: apps/admin/e2e/tc-243-coach-availability-strict-still-blocks.e2e.ts — REGRESSION: strict (default) still raises court_reserved_for_coach.
E2E: apps/admin/e2e/tc-244-open-mode-allows-other-coach-lesson.e2e.ts — open availability allows another coach's lesson on the same court at the same time (user's specific clarification).
E2E: apps/admin/e2e/tc-245-lesson-displaced-by-booking.e2e.ts — DEC-248 displacement guard raises when a booking has already taken the court at lesson time. Uses asymmetric 15:30–16:30 window so the bookings-table no-overlap unique index doesn't fire first.
E2E: apps/admin/e2e/tc-246-schedule-editor-open-toggle-persists.e2e.ts — UI flow: open the editor, tick the open-mode toggle, save, verify DB row has blocks_courts=false (end-to-end UI → PUT route → RPC → DB).
E2E: apps/admin/e2e/tc-247-exception-on-open-availability-422.e2e.ts — PUT route returns 422 with code exception_on_open_availability when the proposed exception targets only open availabilities (DEC-253).
DEC-245 (column name blocks_courts), DEC-246 (scope locked, auto-fallback deferred), DEC-247 (lesson_court mode-agnostic), DEC-248 (displacement guard), DEC-249 (DB-trigger cache invalidation + LISTEN), DEC-250 (calendar visual), DEC-251 (open rows still count for programs/packages), DEC-252 (toggle polarity), DEC-253 (exception-on-open 422), DEC-254 (two open availabilities may overlap), DEC-255 (consumer-agnostic protocol) logged with full alternatives and rationale.
Two follow-up fixes to the v0.31.0 coach-availability work, both landed 2026-06-01. (1) PR #697 — OpenRally was deleting a coach's WHOLE availability window whenever any time-off exception merely overlapped it, instead of subtracting only the excepted interval, so a coach with partial time-off lost slots in TPS (or vanished from coach discovery) that Setpoint admin still correctly showed. Both OpenRally read paths used a `NOT EXISTS (… any-overlap …)` row drop and lacked DEC-237 per-court scoping; Setpoint's four other paths already did correct half-open interval subtraction. Fixed via one shared SQL helper `coach_lesson_windows_for_date(p_facility_id, p_coach_id, p_date)` = `range_agg(availability) − range_agg(exceptions)` (per-court scoped `(ae.court_id IS NULL OR ae.court_id = court)`, whole-day exception zeroes the day, half-open `[)`, NO blocks_courts filter per DEC-247 — lesson eligibility ≠ court hold), called by both `coach_availability.rs` (handler) and `coach_search.rs` (has_availability). Lesson-eligibility twin of `coach_blocked_intervals_for_facility`. Dead `list_availabilities`/`list_availability_exceptions` helpers (same buggy pattern) deleted. Response shape unchanged → no protocol version bump. Already deployed to prod as a hotfix; PR #697 is the record. See DEC-256. (2) PR #698 — saving a coach's schedule could fail with the strict-overlap error even for edits unrelated to the conflicting court, because an open window (blocks_courts=false) was silently coerced to strict (the route mapped `blocks_courts ?? true` and `coach_schedule_replace` did `COALESCE(...,true)`, so a MISSING flag became strict and then genuinely collided). Fixed by making blocks_courts faithful end-to-end and failing loud: `coach_schedule_replace` drops the COALESCE default + RAISEs on a missing boolean + takes a per-(facility,court) `pg_advisory_xact_lock`; the admin route returns 422 on missing/non-boolean and calls a new pre-save check; new `coach_availability_overlaps(coach, facility, availabilities)` enumerates strict×strict same-court overlaps with the IDENTICAL predicate as the overlap trigger (recurrence-date intersection, half-open ranges) so the operator gets an actionable 409 overlaps[] list instead of an opaque raise — and never falsely strands legitimate non-coinciding strict pairs. Overlap TRIGGER unchanged (DEC-254 is correct). See DEC-257.
supabase/migrations/20260601043321_coach_lesson_windows_for_date.sql (PR #697) — new STABLE helper coach_lesson_windows_for_date(p_facility_id, p_coach_id, p_date) returning surviving local-time sub-windows (court_id, start_time, end_time) = range_agg(availability) − range_agg(exceptions); per-court scoping (ae.court_id IS NULL OR ae.court_id = av_court_id); whole-day exception zeroes the day; half-open [); NO blocks_courts filter (DEC-247). Lesson-eligibility twin of coach_blocked_intervals_for_facility.
protocol/server/src/handlers/coach_availability.rs (PR #697) — lesson-availability endpoint now SELECT … FROM coach_lesson_windows_for_date(...); the buggy NOT EXISTS any-overlap row drop is gone; slot-slicing loop unchanged.
protocol/server/src/db/coach_search.rs (PR #697) — has_availability now means 'a surviving interval exists' via EXISTS(SELECT 1 FROM coach_lesson_windows_for_date(...)), fixing coaches wrongly disappearing from /coaches?date= discovery.
protocol/server/src/db/coaches.rs + models/coach.rs (PR #697) — delete dead list_availabilities / list_availability_exceptions helpers + their now-unused structs (they carried the same buggy any-overlap pattern).
protocol/server/tests/coach_lesson_windows.rs (PR #697) — 12 tests over the real HTTP endpoints for both sites (split, whole-day, boundary-touch, window-split, adjacent-cover, per-court DEC-237), each asserting parity vs Setpoint's availabilityWindowsForDate and the blocked-intervals helper.
supabase/migrations/20260530074244_coach_overlap_consistency.sql (PR #698) — coach_schedule_replace removes the COALESCE(...,true) default, RAISEs if any availability lacks an explicit boolean blocks_courts, and takes a per-(facility,court) pg_advisory_xact_lock; new coach_availability_overlaps(p_coach_id, p_facility_id, p_availabilities) pre-save STRICT×STRICT same-court overlap enumerator with predicate-parity to the coach_availabilities_check_overlap trigger. Overlap trigger body unchanged. CREATE OR REPLACE only — no data mutation, no DROP.
apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts (PR #698) — stops doing blocks_courts ?? true; returns 422 on a missing/non-boolean blocks_courts; calls coach_availability_overlaps alongside the existing booking pre-check and surfaces a 409 with overlaps[].
E2E: apps/admin/e2e/tc-248-open-window-saves-over-existing-strict.e2e.ts (PR #698) — an open window saved through the real editor over another coach's strict hold succeeds; a strict one is rejected with the readable message.
DEC-256 (OpenRally exception interval-subtraction via shared SQL helper) and DEC-257 (fail-loud on missing blocks_courts + pre-save overlap enumerator) logged with full alternatives + rationale.
Prod-incident fix, code-complete and mergeable (NOT yet deployed — pending PR merge; the postcss build-worm that blocked local builds while this was written was already cleaned in #716, now on main, so the normal merge → Vercel deploy path is safe). Root cause: per-court availability exceptions (DEC-237, v0.30.0) carry an optional court_id — NULL means the time-off applies to ALL of a coach's reserved courts, a UUID means that ONE court only — but several surfaces had hand-rolled copies of the exception-subtraction logic that drifted from the shared availabilityWindowsForDate helper and never applied the court scoping. So a time-off scoped to court B wrongly carved court A's availability. Worst symptom (the reported one, BUG-104): the admin day calendar showed a genuinely coach-held court as FREE, reception booked it, and the authoritative DB gate then rejected with 'reserved for a coach' — recurring reception-blocking confusion at Pad Thai Padel (Royal Padel, courts 10–12). Same drift made the lesson slot pickers hide/over-reject real slots and let the lesson-create/edit gates false-reject. Fix = make availabilityWindowsForDate the single source of truth everywhere and propagate DEC-237 court scoping: validateLessonAgainstAvailability gains an optional courtIds param that pre-filters exceptions to (court_id null OR court in courtIds) before delegating to availabilityWindowsForDate (omit = apply every exception, fully back-compatible with pre-v0.30.0 callers); the calendar day view filters each coach's exceptions to (court_id null OR === this row's court) before both the whole-day check and subtractTimeRanges; the two read-side slot endpoints replace their bespoke per-row subtraction with availabilityWindowsForDate grouped per court (and per coach), making whole-day handling per-court instead of a global coach-off set; the three lesson write paths (create POST, edit PATCH, package generate) project court_id and pass the lesson's courts down to the validator. The known-correct reference twin already doing per-court loops with courtId is availability/route.ts. Verification: a Node type-stripping harness over the actual edited coach-availability.ts passes 10/10 cases (incl. the exact bug — court A keeps 10–12 despite a court-B time-off — all-courts NULL still applies, per-court whole-day scoping, same-court still rejects, and a no-courtId legacy guard); an 8-agent adversarial review (one skeptic per file + independent verify) returned all 8 files correct with zero confirmed issues; tsc --noEmit on the lib is clean with no dangling/unused symbols. Route-level integration is NOT verifiable locally (needs a running Next server; builds/dev are disallowed this session). See DEC-260 (and BUG-104).
packages/common/src/coach-availability.ts — validateLessonAgainstAvailability gains an optional 4th param courtIds?: string[] | null; it pre-filters exceptions to (ex.court_id == null || courtIds.includes(ex.court_id)) before calling availabilityWindowsForDate. courtIds undefined/null applies every exception (pre-v0.30.0 back-compat); a per-court time-off on a different court no longer yields a false 'instructor not available'.
apps/admin/src/components/admin/calendar-grid.tsx (day view — the surface in the reported screenshot) — exceptions SELECT adds court_id; each coach's exceptions are filtered to (ex.court_id == null || ex.court_id === r.court_id) BEFORE both the whole-day zero-out and subtractTimeRanges, so a time-off scoped to court A no longer makes the coach's court-B reservation render as free.
apps/admin/src/app/api/v1/facilities/[slug]/coaches/[id]/available-slots/route.ts — replaces the hand-rolled per-row interval subtraction with availabilityWindowsForDate grouped per court (court_id added to the exceptions SELECT + carried on the mapped rows); removes the now-unused parseHM helper.
apps/admin/src/app/api/v1/facilities/[slug]/lessons/available/route.ts — replaces the hand-rolled subtraction with availabilityWindowsForDate per (coach, court); court_id added to the SELECT and to exceptionsByCoach; whole-day handling is now per-court instead of a global coachIsOff set; removes the now-unused toMinutes helper.
apps/admin/src/app/api/admin/lessons/route.ts (create POST) — exceptions SELECT adds court_id; passes the lesson's court_ids to validateLessonAgainstAvailability so a different-court time-off can't false-reject the create.
apps/admin/src/app/api/admin/lessons/[id]/route.ts (edit PATCH) — exceptions SELECT adds court_id; passes courtIds to validateLessonAgainstAvailability (undefined on time-only edits that don't change courts → legacy apply-all behavior, the authoritative trigger still gates).
apps/admin/src/app/api/admin/player-packages/_lib/generate-lessons.ts — exceptions SELECT adds court_id; passes each occurrence's courts (sched.court_ids || [defaultCourtId]) to validateLessonAgainstAvailability so a per-court time-off no longer causes a false skip.
apps/admin/src/app/api/v1/facilities/[slug]/coaches/[id]/route.ts — adds court_id to the public availability_exceptions projection (a contract gap: consumers previously couldn't scope per-court exceptions when resolving windows).
Deferred follow-ups (documented, intentionally NOT in this PR) — FOUR availabilityWindowsForDate call sites still run court-agnostic; none can reproduce BUG-104 (all conservative under-show, never show a held court free): (a) availability/range/route.ts — coarse calendar day-DOTS ('does ANY coach have ANY 60-min slot'), documented as an intentional ballpark; exact drill-in via availability/route.ts; LOW, clean fix = group coachRows by court. (b) coach-calendar-tab.tsx — single-coach MONTH overview renders one MERGED bar from coalesced court_ids[] rows (no per-court grain); proper fix = per-court rendering/union; cosmetic backlog. (c) lesson-form-fields.tsx and (d) lesson-form-dialog.tsx — INTENTIONAL court-agnostic 'when is the coach available at all' date hints that already delegate the authoritative per-court slot/court-chip computation to the now-fixed available-slots route; correct by design.
DEC-260 (make availabilityWindowsForDate the single source of truth across all calendar/booking surfaces + propagate DEC-237 per-court exception scoping) logged with the root cause, the drift-from-shared-helper diagnosis, and the remaining court-agnostic surfaces.
Two Nestor-requested calendar improvements. (1) Toolbar UX (PRs #893/#894): the category dropdown becomes a checkbox multiselect ('Show all' + Court Booking / Events / Coaching — CalendarCategoryFilter changes from a single string to readonly CalendarCategory[], union semantics preserved verbatim via .some()); the Coach availability button becomes a labeled Switch; the Horizontal/Vertical text button becomes a right-aligned segmented icon pair, and the day view now defaults to vertical. (2) Occupancy badge: each calendar view shows booked ÷ open court-minutes for its visible window (Day = 1 day, Week = 7 days, Month = calendar month) via the new GET /api/admin/occupancy?facility_slug&start&days endpoint, which reuses computeAvailableMinutes (court_schedules-derived denominators, the same formula as Reports 7d utilization) scoped to an explicit facility-local window. Badge is a shared component rendered in all three toolbars with a booked/open-hours tooltip; fetch failure surfaces as an em dash + explanatory tooltip (no silent drop). Bonus: tc-021 and tc-132 still used pre-OTP email+password login (broken since PR #802) — both switched to loginViaOtp.
apps/admin/src/components/admin/calendar-category-filter-select.tsx — Select → DropdownMenu checkbox multiselect with Show all reset; onSelect preventDefault keeps the menu open; trigger label Show all / name / N selected / None selected.
apps/admin/src/components/admin/calendar-occupancy-badge.tsx (new) — shared Day/Week/Month readout with Gauge icon, booked/open-hours tooltip, self-contained TooltipProvider, error state surfaced to the operator.
week-calendar-grid.tsx / month-calendar-grid.tsx — badge right-aligned in each toolbar (week = weekDates[0]+7d, month = 1st + daysInMonth).
E2E: apps/admin/e2e/tc-263-calendar-occupancy.e2e.ts — one 120-min booking against the 3-court 07:00–22:00 fixture asserts exact per-view percentages (Day 4% / Week 1% / Month 0%) plus the tooltip breakdown.
E2E repairs: tc-021 + tc-132 password login (dead since the OTP migration, PR #802) → loginViaOtp; tc-254 filter helper drives the multiselect.
After v0.29.0 shipped, the Pad Thai Padel manager reported a follow-up blocker — she's reserving a coach Thursdays Jun 1–30 on The Padel Society court at 10–13. Jun 25 has existing customer bookings 11–12 and 12–13 on that court. The reverse-direction conflict gate (DEC-228) refused to save. Her quote: "could we add the court selection in the exceptions area, so we can make some changes to the specific court?" Three coordinated changes ship together. (1) PER-COURT EXCEPTIONS (B, DEC-237): availability_exceptions gains optional court_id. NULL = all reserved courts (preserves the pre-v0.30.0 semantics for every existing row). NOT NULL = scoped to that court. Matches the per-court grain of coach_availabilities. (2) GATE HONORS EXCEPTIONS — BUG-093 FIX (DEC-238 / R6 in known-issues.ts): coach_schedule_replace reorders to INSERT exceptions BEFORE availabilities so the per-row backstop trigger sees them when it fires. check_booking_against_coach_reservations + coach_availability_conflicting_bookings both subtract applicable exceptions scoped by court_id. The route's pre-check additionally filters its DB-aware conflict list against proposed (request-body) exceptions in TS so the operator can resolve conflicts in the same save. (3) INLINE 'IGNORE THIS CONFLICT' BUTTON (D, DEC-239): each row in the red conflict panel gets a one-click button that injects an exception pre-filled with the conflict's court + date + time. Multi-round multi-agent debate trail in DEC-237 (architect / UX realist / DB auditor / minimalist; cross-flip in round 2). Rejected: Option A (R6 fix only — silently destroys multi-court reservations); Option C (per-availability-row exceptions — over-normalized, cascading delete orphans).
supabase/migrations/20260527073858_coach_availability_per_court_exceptions.sql — ALTER TABLE availability_exceptions ADD COLUMN court_id uuid NULL REFERENCES courts(id) ON DELETE CASCADE; extended UNIQUE constraint to (coach_id, facility_id, date, start_time, court_id); CREATE INDEX idx_availability_exceptions_court; CREATE OR REPLACE coach_schedule_replace with INSERT-reorder (exceptions FIRST, availabilities SECOND); CREATE OR REPLACE check_booking_against_coach_reservations + coach_availability_conflicting_bookings to subtract exceptions scoped by (ae.court_id IS NULL OR ae.court_id = ca.court_id).
apps/admin/src/app/api/v1/facilities/[slug]/availability/route.ts — SELECT includes court_id; per-court iteration passes courtId so a Court A exception only carves Court A.
apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts — IncomingException.court_id; validateIncomingException UUID-validates court_id; expandedExceptions passes through to coach_schedule_replace. NEW filterByProposedExceptions helper drops conflicts that the operator's PROPOSED exceptions would carve (the matcher reads exceptions from the DB which doesn't have the proposed ones yet — TS filter bridges the gap). Race-handler reuses the same filter.
apps/admin/src/components/admin/coach-schedule-editor.tsx — ExceptionEntry.court_id; ExceptionRow now has a Court chip subrow (All courts + one per facility court, single-select; aria-pressed; reuses chipClass from v0.29.0). Conflict panel gains 'Ignore this conflict' button per row — one click injects an exception with court_id/date/time pre-filled and marks the conflict row 'Carved out ✓' (struck-through + disabled).
E2E: apps/admin/e2e/tc-161-coach-availability-per-court-exception.e2e.ts — seeds coach on Court A + B with weekly Mon 09–17; operator adds Court-A-scoped exception 11–13 via UI; verifies Court A 12:00 → available, Court B 12:00 → still coach_reserved, Court A 10:00 → still coach_reserved (outside window). Reopens editor to verify chip selection round-trips.
E2E: apps/admin/e2e/tc-162-coach-availability-gate-honors-exceptions.e2e.ts — seeds existing confirmed customer booking; operator's first save fails with conflict panel; adds court-scoped exception covering the conflict; second save succeeds; original booking preserved.
E2E: apps/admin/e2e/tc-163-coach-availability-ignore-conflict-button.e2e.ts — same setup but operator clicks 'Ignore this conflict' button; exception row materializes pre-filled with court + date + time; button flips to 'Carved out ✓' and disables; second save succeeds. Full coach-availability sweep tc-136..163 (42 runs) green; no regressions.
Closes the operator trap discovered during a desk walkthrough: in the admin create-booking dialog, picking the wrong customer from typeahead left the operator stuck — the X (remove) button was guarded by `players.length > 1`, so the lone organizer could not be cleared. Workaround was hostile (add a fake second player to expose the X on row 0, then remove both). Fixed end-to-end with three coordinated changes (multi-agent debate trail in /workspaces/setpoint-3/plan-organizer-clear-fix.md). (1) UI — dropped the `> 1` guard at booking-players-input.tsx:266, added aria-label + data-testid on the X, and refocus the typeahead input when the list goes empty (closes the 'stare at empty card' gap). (2) Dialog — onPlayersChange fully resets organizer-derived parent state on empty (guestEmail, guestFirstName/LastName/Phone, customerMatch, memberInfo, passInfo) so a wrong-pick recovery cannot leak member-tier pricing / pass / contact fields from the discarded organizer into the next pick or the submit payload. (3) Backend — DELETE /api/admin/bookings/[id]/players promotes a remaining player to organizer (mirroring the UI's behavior) and updates bookings.user_id + guest_email to match the new organizer. A partial unique index `booking_players_one_organizer_per_booking` ON (booking_id) WHERE is_organizer was initially part of this PR and reverted before prod deploy. Its fail-loud pre-flight surfaced 1 pre-existing prod booking (Alba, 2026-04-24) with 0 organizers — a real data anomaly the partial unique would not have caught anyway (it only guards >1, not 0). Filed as BUG-096 for follow-up investigation; the unique index will re-land alongside that fix in a separate PR. Deferred to follow-up: labeled 'Change' affordance, click-crown-to-swap-organizer, dropdown disambiguation (phone + last-booking-date), the full organizer-as-first-class column refactor, and re-introducing the unique index after BUG-096 is investigated. See DEC-236 for the full debate (Minimalist + Architecture Skeptic + Operator Empathy composite; UX Advocate's 'Change' label rejected for this PR).
apps/admin/src/components/admin/booking-players-input.tsx — drop `players.length > 1` guard on the row-remove X button (line 266); add aria-label `Remove organizer`/`Remove player` and data-testid `remove-player-{idx}`; in handleRemovePlayer, focus inputRef when the list becomes empty.
apps/admin/src/components/admin/create-booking-dialog.tsx — onPlayersChange handler on the BookingPlayersInput now branches on updated.length === 0 and clears guestEmail, guestFirstName, guestLastName, guestPhone, customerMatch, memberInfo, passInfo. Prevents stale member-tier pricing / pass / contact fields from the discarded organizer leaking into the next submit or the next pick's recomputation.
apps/admin/src/app/api/admin/bookings/[id]/players/route.ts — DELETE handler now looks up the target row's is_organizer first, then after a successful delete promotes the next remaining player (ORDER BY id ASC LIMIT 1) to organizer when the deleted row WAS the organizer. Also updates bookings.user_id + guest_email to the new organizer's values. Mirrors the UI's handleRemovePlayer behavior so non-UI callers (third-party API consumers, future bots) get the same invariant.
E2E: apps/admin/e2e/tc-160-organizer-row-clearable.e2e.ts — two sub-tests. TC-160a drives the UI: open dialog, pick WRONG customer, click X on the lone organizer (would have failed without the fix), assert search input is focused, pick the RIGHT customer, submit, assert in DB that the booking landed with RIGHT as organizer and exactly one is_organizer=true row points at RIGHT. TC-160b seeds a booking with two players via admin client, logs in as admin, calls the real DELETE route, asserts the remaining player was promoted and bookings.user_id/guest_email match the new organizer. Both green locally. (TC-160c, the partial-unique-index test, was dropped together with the migration revert; it re-lands when the index does.)
Reverted before prod deploy: supabase/migrations/20260527062452_add_unique_organizer_per_booking.sql (partial unique index). Pre-flight check surfaced BUG-096 (Alba's booking has 0 organizers); the index is the wrong tool for that case (it only guards >1) and re-introducing it requires deciding the orphan-organizer cleanup pattern first.
DEC-236 logged with the full 4-agent debate (Minimalist Engineer, UX-First Advocate, Architecture Skeptic, Operator Empathy) + synthesizer composite + rejected alternatives (labeled 'Change' button, in-place edit mode, full organizer-as-first-class refactor); subsequently updated with the partial-unique-index revert and BUG-096 cross-reference.
plan-organizer-clear-fix.md at the repo root persists the multi-agent debate trail and decision rationale so the follow-up PRs (click-crown-to-swap-organizer, dropdown disambiguation, full organizer-column refactor, unique-index re-introduction) can pick up the context without re-litigating.
Operators couldn't express 'every Wednesday from Jun 10 to Jun 24, 11–2pm' — Weekly was unbounded (applies forever, can't defer start), and Date range covered every day with no day-of-week filter. Pad Thai Padel manager flagged this as blocking. Fixed both at once: (A) date_range.recurrence_config gains optional days_of_week filter; (B) weekly + monthly.recurrence_config gain optional start_date/end_date bounds. Both fields are additive — omitting them preserves today's behavior, so all existing rows continue working unchanged with zero data migration. The schema's recurrence_config_shape CHECK was relaxed to permit the new optional keys; coach_availability_applies_on_date and coach_availability_dates were extended to honor them; the program-session validator (validateProgramSessionsAgainstAvailability) was rewritten from a weekly-only filter to date-span iteration that accepts both bounded weekly AND date_range[days_of_week] as program-compatible — see DEC-234 for why the old shape was a silent footgun. Editor UI added an Active-dates From/To subrow under weekly/monthly and a Repeats-on day-of-week chip subrow under date_range with all-7-implicit-when-absent semantics. Rust OpenRally server required zero changes (recurrence_config is opaque JsonValue). Multi-round multi-agent debate behind the decision in DEC-232; semantic-redundancy trade-off accepted in DEC-233; editor field-carryover behavior in DEC-235 (asymmetric: date_range→weekly carries days_of_week, weekly→date_range does NOT, because the default Mon-1 placeholder isn't an intentional selection). Side fix: fanOutWeeklyRows at apps/admin/src/app/api/v1/facilities/[slug]/coaches/[id]/route.ts skips bounded weekly rows in the legacy `availability` field — the legacy shape can't carry bounds, and silently emitting day_of_week without bounds would mislead consumers; bounded recurrence is available via `availability_windows`. lesson-form-fields + lesson-form-dialog summaries now include both shapes filtered by the picked date's bounds.
supabase/migrations/20260527025804_coach_availability_bounds_and_dow.sql — DROP+ADD recurrence_config_shape CHECK to permit optional start_date/end_date on weekly+monthly and optional days_of_week on date_range; CREATE OR REPLACE coach_availability_applies_on_date with new bounds + dow filter; CREATE OR REPLACE coach_availability_dates with bound-aware materializer (clamps weekly/monthly horizon to bounds when present, preserves past-bounded rows for overlap-trigger symmetry).
packages/common/src/coach-availability.ts — appliesOnDate updated to honor bounds + days_of_week filter; validateProgramSessionsAgainstAvailability rewritten with required ProgramSpan { start_date, end_date } arg, enumerates per-session dow occurrences inside the span and requires at least one applicable+covering row per occurrence. Accepts both bounded weekly and date_range[days_of_week]; rejects bounded rows narrower than the span. New helper enumerateDowOccurrences. Old 'weekly-only filter' removed.
apps/admin/src/app/api/admin/programs/route.ts + apps/admin/src/app/api/admin/programs/[id]/route.ts — POST passes { start_date, end_date } to validator; PATCH computes effectiveStart/End from updateData/existing and passes through. PATCH also extended its existing program SELECT to include start_date/end_date.
apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts — validateIncomingAvailability extended: weekly/monthly accept optional start_date/end_date (both-or-neither, ISO, start ≤ end) via new validateOptionalBounds helper; date_range accepts optional days_of_week (non-empty array of integers 0–6).
apps/admin/src/components/admin/coach-schedule-editor.tsx — new Active-dates From/To subrow on weekly/monthly with data-testids -bounds-start/-bounds-end; new Repeats-on chip subrow on date_range with data-testids -date-range-dow-{0..6} and all-7-implicit when days_of_week absent; toggleDateRangeDow materializes [all 7 minus toggled] on first click and collapses back to undefined when restored to 7; changeRecurrenceType asymmetric carryover (date_range→weekly carries days_of_week; weekly→date_range does not — default Mon-1 placeholder protection); labels refreshed (Days → Repeats on); aria-pressed on chips; clientValidateAvailability extended with bounds + dow validation.
apps/admin/src/components/admin/lesson-form-fields.tsx + lesson-form-dialog.tsx — coach-weekly-summary 'recurrence_type === weekly' filter dropped; now includes bounded weekly AND date_range[days_of_week] rows whose bounds (if any) cover the picked lesson date.
apps/admin/src/app/api/v1/facilities/[slug]/coaches/[id]/route.ts — fanOutWeeklyRows skips bounded weekly rows (legacy `availability` field can't carry bounds); comment updated to steer consumers to `availability_windows`.
protocol/server — zero Rust changes required. recurrence_config is read as sqlx::types::JsonValue and passed opaquely to coach_availability_applies_on_date in SQL. cargo build + cargo test clean.
E2E: tc-153-coach-availability-weekly-bounded.e2e.ts — operator schedules every Wed bounded over 3 Wednesdays via Weekly+bounds; in-range Wednesdays fire, out-of-range Wednesdays don't, Tue/Thu in-range don't, bounds round-trip in editor.
E2E: tc-154-coach-availability-monthly-bounded.e2e.ts — operator schedules 15th-of-month bounded over 3 months; 15th of in-bound months fires, 15th of out-of-bound months doesn't.
E2E: tc-155-coach-availability-date-range-dow-filter.e2e.ts — manager's literal scenario: operator picks Date range + Wed/Fri filter; Wed/Fri in range fire, other dows in range don't, dates outside range don't.
E2E: tc-156-program-coach-availability-bounded-validator.e2e.ts — 12-week program past bounded coach's end_date rejected with 400; 4-week program inside bounds accepted; 12-week program against date_range[Mon] covering full span accepted. Two coaches on separate courts to avoid the existing overlap trigger.
E2E: tc-157-coach-availability-backward-compat.e2e.ts — pre-A+B rows (unbounded weekly, unbounded date_range) inserted directly via admin client behave identically; no spurious default keys appear on round-trip.
DEC-232 through DEC-235 logged with full multi-round debate trail + rejected alternatives (A-only / B-only / Option C full RRULE / new bounded_weekly type / write-time normalization).
5 E2E tests
v0.28.1Shipped
Dashboard report calculation fixes
26 May
Resolves three compounding bugs in GET /api/admin/reports that produced 'Utilization: 309%' and a 'Bookings (7d)' card that drifted upward as new far-future bookings landed at Pad Thai Padel. (1) The Supabase query filtered `start_time >= 30 days ago` with no upper bound — every future booking (next week, December) was being added to every 7d / 30d aggregate. Adding `.lte('start_time', now)` fixes Bug 1 in one place so every downstream JS filter inherits the bounded window. (2) The utilization denominator hardcoded `12 * 60 * 7 * courtCount` instead of reading real opening hours from `court_schedules` — PTP courts are open 16.5 h/day, not 12. Replaced with a new shared helper `apps/admin/src/lib/court-open-hours.ts → computeAvailableMinutes()` that sums per-(court, day_of_week) open minutes across the rolling 7-day window and returns both `total` and `byCourtId` so the top-card and per-court table share the same denominator. (3) Wrapped every utilization percentage in `Math.min(100, Math.round(...))` as defense-in-depth so a stale schedule (court closed in DB, bookings still on it) can never read >100% again. Academy-reports endpoint audited in the same PR — it uses `facilityDateBoundaries` for both lower and upper bound and has no impossible-value formulas, so no fix needed. Verified against prod (read-only): PTP utilization 309%→59%, 'Bookings (7d)' ~1,368→218. TC-033 dashboard E2E extended with a +30-day future-booking regression guard and assertions that the top-card + per-court utilization values are ∈ [0, 100]. See DEC-231 for the full self-debate (hot-patch only / operating_hours JSONB / SQL view / fixed calendar week all rejected).
apps/admin/src/lib/court-open-hours.ts — new shared helper that reads court_schedules and returns `{ total, byCourtId }` open minutes for a rolling window. Default-zero for (court, dow) pairs where the court has no `is_available=true` schedule row.
apps/admin/e2e/tc-033-dashboard.e2e.ts — beforeAll now seeds one +30-day future booking; 33b tightened to assert exact count `=== 5` (excludes the future + cancelled); new 33j asserts top-card Utilization ∈ [0, 100]; new 33k asserts every per-court Utilization row ∈ [0, 100]. 12/12 green locally.
academy-reports route audited (uses facilityDateBoundaries with both bounds; no hardcoded denominator; no impossible-value formula). No code change required — documented here so the next agent doesn't re-litigate.
DEC-231 logged with full alternatives + rationale (hot-patch / operating_hours JSONB / SQL view / fixed calendar week all rejected, with reasons).
3 E2E tests
v0.27.0Shipped
Reverse flow — Admin lessons surface in TPS (Setpoint→TPS writeback)
17 May
Closes the missing reverse direction of the OpenRally integration: when a Setpoint admin creates a lesson natively (front desk takes a phone call, /academy/lessons), the matched TPS user now sees it in their existing 'My coach sessions' screen indistinguishable from a TPS-initiated booking. Per the new CLAUDE.md principles 'OpenRally is just a protocol' + 'Native-equivalence rule for Setpoint ↔ TPS integration' — zero new UI surfaces on either side. The whole feature is plumbing: an AFTER INSERT trigger on lesson_players inserts a `lesson_created` row into or_events with the primary player's email; the existing OpenRally webhook dispatcher delivers it to TPS's registered endpoint; a new TPS-side Edge Function (`openrally-lesson-webhook`) looks up the TPS user by email (DEC-223 identity bridge), looks up the TPS coach by email, and writes three TPS-native rows — `bookings`, `coach_reservations`, `booking_third_party_references` — using the exact shape `addBookingData` writes for TPS-native coach bookings. TPS's existing ProfileCoachingScreen renders the writeback rows identically to self-initiated bookings; downstream actions (cancel, check-in, payment) flow through the same code paths. Cancel symmetry is automatic — the same Edge Function listens for `lesson_cancelled` (the trigger from v0.26.0's emit_lesson_status_event already fires) and flips bookings.status + coach_reservations.status to 'cancelled'. Idempotent both ways via booking_third_party_references unique key on (platform_name, third_party_reference), mirroring the proven Sterling/Kross/Playtomic pattern on the TPS side. Outbound webhook signing by OpenRally Rust is active; receiver HMAC verification deferred per the 2026-04-20 alignment — when org-wide rollout happens it lands on all three inbound TPS webhooks (this one + webhook-setpoint-booking-update + webhook-external-cancellation) together. See DEC-225 for the full self-debate (polling, shared DB, per-render fetch, hybrid push+poll all rejected) and industry alignment (Stripe Connect / Plaid / Twilio all use webhook push for producer→consumer state-change notifications).
Setpoint migration: supabase/migrations/20260517072915_emit_lesson_created_for_admin_lessons.sql — AFTER INSERT trigger on lesson_players (fires when is_primary=TRUE AND email IS NOT NULL) inserts a `lesson_created` row into or_events. Joins coaches→customers for coach_email (DEC-223 identity bridge) and facilities for currency. Payload: lesson_id, facility_id, coach_id, coach_email, class_id, start_time, end_time, primary_player_email, primary_player_phone, num_courts, court_ids, price_per_pax_amount, currency, payment_method, emitted_at. Trigger gated by lesson.status='scheduled' so backfills into terminal states don't emit. Applied to prod and verified via information_schema.triggers query.
padel-backend Edge Function: supabase/functions/openrally-lesson-webhook/index.ts (379 lines) — Deno.serve handler with OPTIONS CORS + POST-only check. Resolves TPS club via clubs_3rd.platform_name='setpoint', TPS user via users.email ILIKE, TPS coach via coach_profiles + users.email ILIKE (all DEC-223 email-as-identity). Idempotency: SELECT existing btpr row before insert + 23505 conflict handler for concurrent deliveries with manual rollback of partial writes. Bookings INSERT shape mirrors addBookingData (court_id, user_id, booking_date, booking_date_time, time, base_amount, tax_amount, total_amount, type='coaching', currency). Cancel handler flips bookings.status + coach_reservations.status to 'cancelled' on lesson_cancelled events, idempotent via already-cancelled precheck.
padel-backend config.toml: [functions.openrally-lesson-webhook] verify_jwt=false — matches the sibling webhook-external-cancellation + webhook-setpoint-booking-update pattern. Shipped as a focused chore PR (padel-backend #200).
Setpoint prod: openrally_webhook_endpoints row inserted for Pad Thai Padel (facility_id=2b2fc986-23f6-45d7-9f56-f1f508c8477a) pointing at the deployed Edge Function URL with event_types=[lesson_created, lesson_cancelled], is_active=true. signing_secret is a real 32-byte hex value — Rust dispatcher signs outbound today even though receiver verification is deferred (so no rotation needed when HMAC verification is turned on later).
E2E: apps/admin/e2e/tc-144-admin-lesson-emits-openrally-event.e2e.ts — admin login → calendar → click empty slot → switch to Lesson → fill class/coach/time/court/primary-player-with-email → submit → asserts an or_events row with the right payload shape is emitted. Guards against future changes to create_lesson_atomic's insert order or trigger payload regressions.
DEC-225 logged with full alternatives (polling, shared DB read, per-render fetch, hybrid push+poll) + counter-arguments + industry alignment (Stripe Connect / Plaid / Twilio). Self-debate confirmed: the design adds zero UI surfaces on either side and zero TPS-specific concepts in OpenRally — exactly what the new 'OpenRally is just a protocol' + 'Native-equivalence' CLAUDE.md principles demand.
CLAUDE.md principles 'OpenRally is just a protocol — don't reinvent UI/UX on either side' and 'Native-equivalence rule for Setpoint ↔ TPS integration' added prior to this feature; v0.27.0 is their first application. Both go in the STRONG BIAS section and explicitly cover reverse-direction cross-system features.
First real-device test of the v0.27.0 reverse flow (2026-05-18 on iPhone 1.0.75) surfaced that OpenRally coach bookings in BOTH directions were invisible in the TPS mobile app's coaching list (You → Plan → Coaching showed 'Book your first session' empty state despite valid bookings). Root cause: v0.25.0 / DEC-208 (forward flow) and v0.27.0 / DEC-225 (reverse flow) both intentionally skipped writing TPS-native `coach_reservations` + `coaching_slot_details` rows, reasoning 'the lesson lives entirely on the OpenRally side.' That reasoning ignored that the TPS mobile app's coaching list query (`coaching_repository.dart:148-182`) INNER JOINs on those tables — so a booking shaped as 'just bookings + booked_slots + btpr' is filtered out of the user-facing list regardless of how Setpoint stores the lesson. Per CLAUDE.md Native-equivalence rule, a Setpoint-admin-created lesson and a TPS-app-created booking must produce TPS rows indistinguishable from a TPS-native coach booking. DEC-208 (and its v0.27.0 mirror) reversed in DEC-226. Forward flow now writes coach_reservations at HOLD + flips to confirmed + inserts coaching_slot_details at CONFIRM, mirroring legacy lifecycle. Reverse flow writes all 5 native rows on inbound lesson_created. Four compounding bugs found in sequence (each hid the next): bookings.status='created' instead of 'confirmed' (no downstream flip in OpenRally path); booked_slots row absent in reverse; coaching_slot_details row absent in both (inner-join filter); coach_reservations.expiry_time=null → mobile app's Coaching.fromJson() throws on required non-nullable DateTime → ProfileCoachingController catch block only logs in kDebugMode → silent empty list in release builds. Verified end-to-end on TPS prod with two fresh test bookings (one forward, one reverse) rendering correctly on real iPhone. Independent of this patch: OpenRally dispatcher on Fly.io is silently dropping some events (no webhook_deliveries row created) — filed as BUG-086, manual NOTIFY/curl as stopgap. TPS mobile app silent error swallow in production catch blocks filed as BUG-087.
padel-backend Edge Function: openrally-lesson-webhook/index.ts — extended to write 5 TPS-native rows in correct order (bookings status='confirmed', booked_slots, coach_reservations status='confirmed' with expiry_time=end_time, coaching_slot_details status='pending' created_user=tpsUserId, btpr). Each insert has rollback on failure. Sub-court lookup picks the lowest-id active club_courts.id for the matched club. Idempotent via btpr unique constraint.
padel-backend Edge Function: confirm-coaching-booking-v2/index.ts (OpenRally branch) — after OpenRally protocol confirm, looks up coach_reservations by booking_id and calls coachService.confirmReserveCoach + createCoachingSlotDetails. Same coachService methods the legacy path uses below at lines 130-131 of the same file. UPDATEs cr.status='confirmed' + INSERTs coaching_slot_details with chat_group_id from Amity (best-effort, swallowed if Amity unreachable).
padel-backend Edge Function: create-coaching-booking-v2/index.ts (OpenRally branch) — inline INSERT coach_reservations after the btpr insert. Inline rather than coachService.reserveCoach because that service has side effects that conflict: it calls getCoachPriceForReservation against TPS-side coach_profiles.lesson_rate_amount (wrong source — pricing lives on Setpoint for OpenRally clubs) and additively UPDATEs bookings.amounts (would double-count). Shape matches native: status='reserved', expiry_time=+10min, so native expiry/cancel logic works automatically.
Deployed all three Edge Functions to TPS prod (project tnyqbaqnugjgefkdvmag) on 2026-05-18. End-to-end verified with: (1) fresh TPS app booking (id 59677, Tue May 26 09:00 BKK) renders in iPhone Plan → Coaching with coach + player avatars — forward flow works without manual patching, (2) fresh Setpoint admin lesson (2bc4bf1b, Tue May 26 14:00 BKK) writes TPS booking 59680 with all 5 native rows via deployed code — reverse flow works (after manual event delivery to bypass dispatcher flakiness).
DEC-226 logged in decisions.ts with the failure trace, the industry-standard reasoning (OTA ↔ PMS dual-store pattern), counter-arguments (thin-client alternative rejected), and confirmation that this aligns with how TPS already integrates with every other PMS (Sterling, BookAndGo, Kross, Playtomic, Taykus, Matchi, Junglepadel — all write TPS-native rows).
BUG-086 filed for OpenRally dispatcher reliability (events emitted but no webhook_deliveries row created — manual delivery as workaround until Fly.io dispatcher gets a fallback poll). Resolved by v0.27.2 below (architectural unification, not patch).
BUG-087 filed for TPS mobile app silent error swallow in ProfileCoachingController (release builds hide all deserialization throws — the only reason it took 12 hours to find the expiry_time=null bug today).
padel-backend PR #202 — three Edge Function source diffs (deployed first, merged second per pre-production boldness).
0
v0.27.2Shipped
Unify webhook delivery on canonical outbox (DEC-226 reversal of DEC-210)
18 May
Retires the parallel webhook delivery system introduced in v0.25.0 / DEC-210 (`openrally_webhook_endpoints` + Rust LISTEN/NOTIFY dispatcher) and unifies on the canonical outbox pattern (`webhook_subscriptions` + `webhook_deliveries` + the pg_cron `webhook-delivery-worker` Edge Function). The schema has had both since April 20; DEC-210 was unaware of the existing system and built a parallel. The Rust path was unreliable in production — Postgres doesn't buffer NOTIFYs for disconnected listeners, so any Fly.io machine restart dropped events on the floor (BUG-086). Rather than patch the Rust dispatcher with a polling fallback, this version retires it entirely. The canonical outbox already has persistent per-delivery retry state, was already proven for booking events, and is already deployed — switching lesson events to it is a one-row INSERT in webhook_subscriptions plus a small wire-format fix on the TPS side. Two-round audit (architecture review + cutover-safety stress test via subagents) caught the wire-format gap before deploy: the worker sends only Content-Type, while the TPS lesson webhook was reading event_type from `X-OpenRally-Event` header (would have silently 200ed and dropped lessons). Companion padel-backend change: openrally-lesson-webhook reads event_type from `body.type` instead of header (matches webhook-setpoint-booking-update pattern). Verified end-to-end in production: fresh Setpoint admin lesson → or_events event 266 → DB trigger creates webhook_deliveries row → pg_cron worker delivers in 4 seconds → TPS booking 59691 written with all 5 native rows → renders on iPhone You → Plan → Coaching. Zero manual NOTIFY needed.
Setpoint migration 20260518084747_unify_webhook_subscriptions_for_openrally.sql — ALTER webhook_subscriptions.partner_name DROP NOT NULL (admin UI doesn't surface this field); add set_updated_at BEFORE UPDATE trigger; enable RLS + add Admins-manage policy on webhook_subscriptions + Admins-read policy on webhook_deliveries (mirrors the policy from the retired openrally_webhook_endpoints, closes the parity gap flagged by audit); re-runnable backfill (WHERE NOT EXISTS guard on facility_id+url) migrating the existing lesson subscription row from openrally_webhook_endpoints. DROP of the old table deferred to a follow-up after a week of stable operation.
apps/admin/src/app/api/admin/webhook-endpoints/route.ts — GET/POST/PATCH/DELETE switched to webhook_subscriptions table. Column renames internally: signing_secret→secret, is_active→active. Response aliases the new column names back to the legacy field names (signing_secret / is_active / last_delivered_at / last_error / last_error_at) so the admin UI card needs no functional change. New fetchDeliveryHealth() aggregates last_delivered_at / last_error / last_error_at at read time from webhook_deliveries (the canonical place for per-delivery state, not denormalized onto subscriptions).
apps/admin/src/components/admin/webhook-endpoints-card.tsx — comment-only update noting the table swap. UI rendering unchanged because of the route's response aliasing.
padel-backend Edge Function: supabase/functions/openrally-lesson-webhook/index.ts — reads event_type and event_id from envelope.type / envelope.id (body) with the original header reads kept as fallback for backward compat during the brief cutover overlap. Matches the proven pattern in webhook-setpoint-booking-update/dispatcher.ts which has been receiving booking events from the same pg_cron worker for weeks.
Cutover order (B → A → D → C per audit): (1) deployed TPS-side openrally-lesson-webhook reading from body; (2) applied Setpoint migration to prod (webhook_subscriptions row + RLS + trigger); (3) merged setpoint PR #630 → Vercel auto-deployed admin route + UI; (4) flyctl deploy --remote-only the Rust without webhook_dispatcher.
End-to-end verified on real iPhone (1.0.75) with gianfranco+2@omnigpt.co: fresh Setpoint admin lesson (db8e07e6-... at Tue May 26 15:00 BKK) → or_events event 266 emitted 09:08:42 UTC → webhook_deliveries row auto-created by fan_out_or_event trigger → pg_cron worker polled within ~10s → POSTed to TPS Edge Function 09:08:46 UTC (200 OK, attempts=1) → TPS booking 59691 written with all 5 native rows → renders on iPhone You-tab Plan→Coaching alongside the previous 4 sessions. 4 seconds end-to-end, zero manual intervention.
BUG-086 (OpenRally dispatcher reliability) → resolved by v0.27.2's architectural unification. The Rust LISTEN/NOTIFY path is gone; the pg_cron outbox guarantees no event is lost on listener disconnect.
DEC-226 logged with full self-debate (Option A sibling-outbox table vs Option B full unify), audit findings (header gap, partner_name NOT NULL, RLS parity, admin UI delivery-health regression), and the no-debt-no-compromise reasoning for Option B over Option C (minimal Rust patch).
Deferred to follow-up: drop openrally_webhook_endpoints table after ~1 week of stable operation on the unified path (Phase 7 task in the agent task list).
0
v0.28.0Shipped
Coach availability ↔ booking conflict gate (CEO Option A)
22 May
Closes the reverse direction of the coach-court-reservation gate. The forward gate blocks a booking landing on a coach reservation; nothing blocked a reservation landing on an existing booking — the 'orphan state' where a court is simultaneously reserved-for-coach AND booked-by-customer, which then hides the customer's real booking from public availability. CEO 'Option A' (strict, all-or-nothing): creating or editing coach availability that overlaps an existing booking now FAILS atomically (nothing written) and the schedule editor lists EVERY conflict inline — court, date, time, and customer — so the operator clears those bookings first. Built as a single shared SQL matcher consumed two ways (DEC-228): the PUT /api/admin/coaches/[id]/schedule route enumerates all conflicts → structured 409 (operator UX path), and a per-row backstop trigger on coach_availabilities raises court_has_existing_booking for the check→replace race or any future non-route writer (mirrors the forward gate's app-precheck + DB-trigger pattern from DEC-202). Conflict scope (DEC-229) mirrors the forward gate exactly: confirmed/checked_in/completed + active holds occupy; cancelled/expired/no_show don't; other coaches' lessons count, this coach's own lessons don't; future/ongoing bookings only. Reuses the facility-timezone + coach_availability_applies_on_date + numrange machinery, so no TS/SQL duplication and all four recurrence types are handled by one path. Building it caught a latent orphan overlap in seed.sql (a demo court booking inside the demo coach's reservation) — fixed. availability_exceptions intentionally not subtracted (BUG-093, conservative over-flag, both layers consistent).
Migration 20260522082857_coach_availability_booking_conflict_gate.sql — coach_availability_conflicting_bookings() core matcher (UTC→facility-local + applies_on_date + numrange overlap; excludes this coach's own lessons), coach_availability_booking_conflicts() enumerate-all wrapper for the route pre-check, and coach_availabilities_check_booking_conflict_trigger backstop. Ships/deploys BEFORE the feature PR per Hole 23.
PUT /api/admin/coaches/[id]/schedule — calls coach_availability_booking_conflicts before coach_schedule_replace; returns 409 { error, conflicts:[{court,date,time,customer}] } and skips the replace when conflicts exist; re-enumerates on the backstop raise (race). formatConflict/resolveBookerName compose display names in the route (presentation out of SQL).
coach-schedule-editor.tsx — inline conflict panel (data-testid booking-conflict-panel / booking-conflict-N) replaces the single-line toast for this path and clears as soon as the operator edits a window. coaches-list.tsx — data-testid + aria-label on the actions trigger and the Schedule item for testability.
seed.sql — moved demo court booking ffffffff…002 out of the demo coach's 19:00-23:30 BKK reservation (was 21:00-22:00 BKK on a reserved court) so a fresh supabase db reset is internally consistent with the new gate.
E2E: apps/admin/e2e/tc-147-coach-availability-booking-conflict.e2e.ts — 7 UI-driven tests through the real editor covering all 8 ACs (reject+list+nothing-created, all-conflicts-listed, no-conflict-saves, edit-preserves-prior-schedule, single_date/date_range/monthly, UTC/Bangkok-boundary date match, cancelled-excluded). 7/7 + setup green.
DEC-228 (hybrid gate layer) + DEC-229 (conflict scope) logged with rejected alternatives. BUG-093 logged for the deferred exception-aware refinement.
7 E2E tests
v0.23.10Shipped
Subscriptions + credit tiers in parallel (better-of pricing)
1 May
Retires the per-facility membership_model toggle and lets subscription discounts and credit-tier discounts run in parallel for every facility. calculate_member_price now computes both percentages independently and applies GREATEST(sub_pct, tier_pct) — better-of wins. The pricing-rule member_price_amount remains a hard per-slot override ahead of either percentage. Loyalty is a navigation umbrella in the admin sidebar — clicking it expands to two sub-pages: Memberships (Members | Memberships tabs) and Wallet (no tabs, just wallet holders). The Memberships tab inside the Memberships sub-page hosts BOTH membership types side-by-side: paid recurring plans on top, rule-based wallet tiers below — letting the two coexist visually the same way they already coexist in pricing math. Per-customer browsing lives in CRM Customers (`/crm/customers` and the detail page), keeping Loyalty configuration-focused. The membership_model JSON key in facilities.settings is no longer read by any consumer (left in place — no backfill).
Migration: supabase/migrations/20260501123207_parallel_subscriptions_and_credit_tiers.sql replaces the if/else dispatch in calculate_member_price with a better-of arm; same JSON shape (price_amount/member_price_amount/is_member/discount_percentage/tier_name) so consumers don't move
Eval harness: supabase/snippets/discount_composition_evals.sql — six matrix cells (subscription only / tier only / both sub-larger / both tier-larger / neither / equal). Red against current schema, green after migration
Admin API: new GET /api/admin/members?facility_slug=... returns the unified list (subscription + tier + balance + total_topups per user, sorted active-subscriptions-first then by topup volume)
Admin API: GET /api/v1/facilities/{slug}/membership-plans + GET /api/admin/credit-tiers no longer return membership_model in their response payloads
Admin UI: facility-settings-form.tsx drops the entire 'Loyalty Model' card (and the membership-model-select testid). The membership_model key is also actively stripped from settings on save
Admin UI: Loyalty is a sidebar umbrella with two child routes — `/[facility]/loyalty/memberships` and `/[facility]/loyalty/wallets` (bare `/loyalty` redirects to memberships). Memberships sub-page has tabs Members | Memberships; the Memberships tab stacks MembershipPlansList (paid plans) over TierConfigTab (rule-based tiers driven by wallet top-ups) — two coexisting membership types in one place. Wallet sub-page has no tabs, just WalletHoldersList. `data-testid='memberships-section'` on the Memberships page wrapper, `data-testid='wallets-section'` on the Wallet page wrapper, `data-testid='assign-membership-btn'` on the Memberships page header (above the tabs). Mirrors the existing CRM/Academy/Settings nav pattern — same shadcn Tabs, same parent-children sidebar idiom, no new design.
Admin UI: new pending-memberships-list.tsx surfaces orphan user_memberships (active rows missing stripe_subscription_id) with View Customer / Get Payment Link / Cancel actions. Renders nothing when empty. unified-members-list.tsx deleted; per-customer browsing lives in CRM Customers (already shows subscription, wallet, and tier on the detail page).
Admin UI: tier-config-tab.tsx no longer wraps its own Card — the Wallets section provides one. Description copy moved to the parent CardDescription.
Web hook: apps/web/src/hooks/use-membership-plans.ts drops membership_model from PlansResponse
E2E: apps/admin/e2e/tc-121-discount-composition.e2e.ts — 4 matrix cells via /api/admin/bookings/price + an API contract test that GET /api/admin/members returns both a subscriber and a tier-only user with the documented sort. UX#4/UX#5 cover Assign Membership empty-state + typeahead via `assign-membership-btn` in the Memberships section header.
E2E updates: tc-110 still operates on `data-user-id` / `member-actions-menu` / `member-get-payment-link` (these now live on the Pending Payments sub-list inside the Memberships section). tc-112 CT-L2-03/03b drop the tab-click and assert against `wallets-section`. tc-112 CT-L2-04 re-targeted at the CRM customer detail Tier panel (`customer-tier-section` / `customer-tier-name`) — the canonical per-customer tier surface.
Docs synced: DEC-183 logged; arch-discounts-snapshot.ts + faq-pricing.ts rewritten so 'Model A / Model B' framing is gone; apidocs membership-plans response example drops membership_model
BUG-044 (resolved): TierConfigTab now has '+ Add Tier' button + dialog. Tier-name dropdown filters to canonical names not yet configured; counter shows 'X of 4'; button auto-disables when all four exist. Pinned by tc-112 CT-L2-03b through the real UI
DEC-184: tiers reduced to threshold + court_discount_pct only. Migration 20260504050515_simplify_tier_to_court_discount_only.sql drops booking_window_days / program_discount_pct / merch_discount_pct / perks columns + rewrites calculate_credit_tier RPC. Add/Edit dialogs simplify to two fields (Threshold + Court Discount %). Tiers now have isomorphic shape with subscription plans (threshold/price + discount %)
BUG-036 (resolved): booking_window_days never-enforced stub closed by removal rather than wiring — see DEC-184
Error-handling hardening: every list component on the Loyalty page (membership-plans-list, tier-config-tab, pending-memberships-list) now distinguishes load-failure from empty — destructive error block with HTTP code + Retry button instead of silently rendering 'No tiers configured yet' / a stuck skeleton. Every mutation handler (handleCancel, handleAddTier, handleSaveTier, handleSavePlan, handleToggleActive) wraps fetch in try/catch so network throws toast a clear message. Pre-existing real bug fixed: membership-plans-list.tsx used to leave the loading skeleton stuck forever on !res.ok (setLoading(false) only ran in the success branch). New testids: `membership-plans-error` / `-retry`, `tier-config-error` / `-retry`, `pending-memberships-error` / `-retry`.
E2E: apps/admin/e2e/tc-122-loyalty-error-handling.e2e.ts — 7 cases pinning the error-handling + UX contract via Playwright `page.route()`: EH-1/2/3 force list-endpoint 500s and assert visible error block + Retry recovery; EH-4/5 force mutation 500s and assert destructive toasts; EH-6 forces a post-mutation refetch failure and asserts the existing rows STAY (toast, not full-screen error block); EH-7 covers the new tier delete via Trash + confirm dialog. Self-contained facility seed in beforeAll, full cleanup in afterAll. Piggybacks on the shared `admin@e2e-test.local` storageState session (granted owner-role in beforeAll) instead of per-test login to avoid cross-suite session flake.
UX hardening pass: (a) Cancel Membership now opens a destructive confirm dialog instead of mutating on dropdown click — single mis-click can no longer end someone's membership. (b) Tier delete affordance added (Trash icon per row + confirm dialog + DELETE /api/admin/credit-tiers backend handler). (c) Error-state gating split: mount-fetch failures show the full ListErrorState block (with Retry); post-mutation refetch failures toast and keep the existing rows on screen (so a successful save isn't visually wiped by an unrelated GET 500). Applies to all 3 list components. (d) Polish: Threshold/Price form labels now show the facility currency unit, e.g. 'Threshold (THB)'; tier empty state gained a CTA hint matching plans; 'Pending Payments' titlecase fixed; Memberships card description trimmed.
BUG-068 → BUG-072 logged in known-issues.ts — deferred Loyalty UX gaps with full agent-fix context (paid-sub cancel surface, cash-payment flow, pending-age column, sort/filter, empty-state placeholder).
BUG-054 (resolved): terminal Today/Yesterday/Tomorrow filter + useTodayPmsBookings now use facility timezone (Intl.DateTimeFormat + facilityLocalToUTC from @setpoint/common/datetime) instead of browser-local startOfDay/endOfDay. Production-affecting silent bug for any operator on a device whose system TZ ≠ facility TZ — receptionists on UTC-set devices saw bookings disappear from the Today filter once Bangkok crossed 17:00. Same canonical TZ helper as 8+ admin API routes.
Admin pattern alignment: /api/v1/lesson-packages/[id]/purchase PaymentIntent rewritten to drop the `customer:` attach + `payment_method_types` workaround — now matches the booking PI pattern (`/api/v1/payments/intent`) exactly. Stripe Link's email/phone gating no longer blocks the Pay button on package purchases. `getOrCreateStripeCustomer` import removed from this route.
Web dialog fix: package-purchase-dialog.tsx adds max-h-[calc(100vh-3rem)] + overflow-y-auto so the Pay button stays in-viewport when content overflows.
E2E stabilization: 8 test files hardened — tc-017 (success regex + dialog scroll), tc-025 (explicit empty storageState + clearStorage), tc-070 (15s→30s timeouts + beforeEach hold cleanup), tc-072 (daysFromNowInTz for facility-tz date math), tc-105 (assert on existing dialog locator instead of re-clicking), flows/cross-app-booking-checkin (search-by-email + Check In All sheet flow + getByTestId('checked-in-indicator') for admin assertion). test-helpers.ts gains `daysFromNowInTz(n, tz)`, `selectOrAddPlayerViaTypeahead()`, and `signInAndGetSession(email, password)`.
Behavior-pinning test: apps/admin/e2e/tc-123-bug-054-terminal-tz-day-filter.e2e.ts — facility in Asia/Bangkok + booking inserted at facility-local 'today 10:00' via facilityLocalToUTC; logs into terminal in headless Chromium (UTC), freezes the page clock to a moment when Bangkok is one calendar day ahead of UTC, and asserts the booking renders under the Today filter. Pinned to the post-fix behavior; the strict pre-fix-fails verification is provided by flows/cross-app-booking-checkin.e2e.ts which exercises the same end-to-end path.
Regression test: apps/admin/e2e/tc-124-lesson-package-pi-no-customer.e2e.ts — POSTs to /api/v1/lesson-packages/{id}/purchase, retrieves the resulting PaymentIntent via the Stripe SDK, and asserts paymentIntent.customer === null + receipt_email matches user.email. Pinned to fail if anyone re-attaches a Customer to the lesson-package PI.
16 E2E tests
v0.23.9Shipped
Wallet Unification — One Add Credits, paid-payment-driven ledger
1 May
Replaces two parallel write paths (topup_club_credits + adjust_club_credits) and two confusing buttons (Top Up + Add Credits) with a single Add Credits dialog mounted on the CRM customer detail page. Every credit add is now tied to a paid payment row; the new grant_credits_on_topup_payment trigger fires on payments.status pending → succeeded for type='credit_topup' and mints the wallet transactions. Cash and Stripe both flow through this single transition: cash via PATCH /api/admin/wallet/topup-payments/[id] (admin clicks Mark Paid), Stripe via the existing webhook. Bonus credits get a second transaction with source='system' on the same payment_id. is_topup column dropped, six legacy transaction types collapsed to three (topup, booking_payment, booking_refund), source column added (customer_payment | admin | system), CHECK invariant enforces topup⇒payment_id and booking_*⇒reference_id. record_credit_transaction is the single write RPC and raises insufficient_balance with structured DETAIL on overdraft (no more silent-clamp). The legacy admin /customers side-panel mount of Add Credits was a wrong-spot interim — now removed; CRM is the single home.
Migration: supabase/migrations/20260430140442_unify_wallet.sql — TRUNCATE wallet tables, add source/payment_id columns + invariant CHECK, drop is_topup + legacy CHECK, drop topup_club_credits + adjust_club_credits, new record_credit_transaction RPC, new grant_topup_credits trigger AFTER UPDATE on payments WHEN OLD.status IS DISTINCT FROM 'succeeded' (idempotency guard against Stripe redeliveries), calculate_credit_tier filters on type='topup', create_booking_with_credits routes through record_credit_transaction
pg_cron: expire_memberships RPC scheduled daily at 01:00 UTC (existed unscheduled since the initial schema)
Eval harness: supabase/snippets/wallet_evals.sql — schema-shape + behavioral assertions runnable via `docker exec -i supabase_db_setpoint psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f - <`. Red against current schema, green after migration
New API: POST/GET /api/admin/wallet/topup-payments and PATCH /api/admin/wallet/topup-payments/[id]; GET /api/admin/wallet/customer-balance
Refactored: /api/v1/credits/topup inserts a pending payment first and stashes payment_id in PaymentIntent metadata; /api/v1/webhooks/stripe credit_topup branches (both checkout.session.completed and payment_intent.succeeded) just flip status — trigger does the wallet write
Refactored: /api/admin/bookings auto-refund + /api/admin/lessons credit-payment now go through record_credit_transaction
New components: AddCreditsDialog (cash + stripe radio, optional bonus, copy-link affordance), PendingPaymentsList. Mounted on apps/admin/src/app/[facility]/crm/customers/[id]/page.tsx in the overview tab
Stripped: credit-wallets-tab + club-credits-tab to placeholders pointing to the new CRM flow (loyalty PR retires both tabs)
Web portal: credit-history.tsx + use-my-credits.ts drop is_topup; switch to notes column (was a latent select-the-wrong-column-name bug)
Central wallet types: packages/database/src/wallet-types.ts — CreditTransactionType, CreditTransactionSource, TopupPaymentMethod enums + API contracts
Plan deviations (kept against the original plan): user_memberships.stripe_price_id and credit_tier_configs.booking_window_days both still actively used (Stripe membership flow + tier-based pricing) — left in place; loyalty PR can revisit. (booking_window_days resolved in v0.23.10 / DEC-184; stripe_price_id still in use)
Resolves BUG-039 (recurring credit bookings now atomic per viable week) plus BUG-041 (route's cost_split_mode default flipped from 'split' to 'organizer' to match DEC-152). For BUG-039: POST /api/admin/bookings recurring branch (weeks > 1) now deducts club credits per viable week through the existing public.create_booking_with_credits RPC introduced in v0.23.7 — each week is its own atomic insert+deduct transaction with the credit-ledger reference_id linked in the same call. Before any write, the route reads the customer's current balance and refuses the whole batch with HTTP 400 if it can't afford `viable_weeks × price` (avoids partial debits). Conflict-skipped weeks deduct nothing; non-credits recurring is untouched. For BUG-041: latent default-value bug — the dialog masked it by always sending cost_split_mode='organizer' explicitly, so no operator-driven booking was ever affected, but direct API callers that omitted the field would have hit the wrong default. No migration — the RPC from BUG-021 is reused unchanged.
Route refactor: recurring branch in apps/admin/src/app/api/admin/bookings/route.ts split into two passes — Pass 1 builds the viable-weeks list (per-week conflict check), Pass 2 creates one booking per viable week
Pre-check: for credit-paid recurring, route reads club_credits.balance_amount and returns HTTP 400 with `Insufficient club credits: have X, need Y for N weeks` if balance < viable.length × finalPrice
Per-week RPC call: each viable credit-paid week goes through supabase.rpc("create_booking_with_credits", ...) — same RPC the single-booking path uses
Per-week RPC error handling: a rare race that surfaces an RPC error mid-loop logs to server console and pushes the week onto skippedWeeks; no orphan ledger row
skippedWeeks sorted ascending so the response is deterministic regardless of skip origin (Pass-1 conflict vs Pass-2 RPC error)
Non-credits recurring (stripe / pay-at-club) unchanged — direct insert path with payment_status='pending'
BUG-041: route's cost_split_mode default flipped from 'split' to 'organizer' in both single-booking and recurring branches — aligns with DEC-152. Dialog always sent 'organizer' explicitly, so no operator regression
Closes the boss's QA comment on recurring court bookings — operators can now configure 'every Wed 5–7 PM until Aug 1' or 'Mon + Wed + Fri for the next 4 weeks' instead of a fixed N-week count. POST /api/admin/bookings drops `recurring_weeks` entirely (DEC-198 hard break) and gains `recurrence_end_date` (YYYY-MM-DD, facility-local) + `recurrence_days_of_week` (number[] of 0=Sun..6=Sat). The route fans out one occurrence per (chip-DoW × date-in-range), pre-checks both booking conflicts AND coach reservations (via the existing check_booking_against_coach_reservations RPC, DEC-202), and returns `skipped_occurrences: [{date, day_of_week, reason}]` with three distinct reasons (`conflict`, `coach_reserved`, `error`). DEC-204 also fixes a latent DST-drift bug — every occurrence's UTC start/end is now constructed via facilityLocalToUTC(date, time, tz) per-occurrence, not by adding 7×86400000 ms to a UTC anchor. The admin booking dialog gains a Start Date + End Date pair (28-day default, 90-day soft cap browser-enforced) and seven day-of-week chips; the click slot's DoW is pre-selected as a hint but chips are the source of truth (DEC-201). A live preview shows 'N bookings will be created: …' and the submit button label switches to 'Create N Bookings'. After submit, skipped occurrences surface in a toast keyed by date + reason. The boss's reported 'system throws error on one Wed already booked' scenario was already resolved in v0.23.8 (BUG-039 per-occurrence skip); this PR is purely the UX + contract change.
Route fanout: enumerate local dates start..end inclusive, filter by chip-DoW, compute per-occurrence UTC via facilityLocalToUTC. Pass 1 runs booking-conflict + coach-reservation pre-checks in parallel per occurrence; Pass 2 (sequential) creates one booking per viable occurrence via the same credit-RPC / direct-insert split as before. Credit-paid wallet pre-check (BUG-039) preserved; multi-player + cost_split side effects per occurrence preserved.
Route helper: checkCoachReservation(supabase, facility_id, court_id, start, end) calls public.check_booking_against_coach_reservations (kind='court_booking') and treats the raise of 'court_reserved_for_coach' as a Pass-1 skip with reason='coach_reserved' (DEC-202).
Admin UI: apps/admin/src/components/admin/create-booking-dialog.tsx — 'Repeat Weekly' switch renamed to 'Make Recurring Booking', panel now has Start Date + End Date inputs and 7 day-of-week chips (Mon..Sun layout, JS DoW values). 90-day soft cap via browser `max` attribute; 180-day hard cap is API-only. Defaults seed from click slot via useMemo + useEffect on `open`. Live preview computes the dates client-side via the same algorithm the route uses.
Admin UI helpers (top of dialog file): addDaysToISODate, dayOfWeekFromISODate, computeOccurrenceDates, formatShortDate — pure functions, easy to reason about. RECURRENCE_DAY_CHIPS exports the Mon..Sun chip ordering with JS DoW values.
Admin UI submit: submit button label switches to 'Create N Bookings' / 'Create N Booking' based on the live preview count. Submit disabled when recurring is on but no chip-DoW falls in the range.
Caller refactor: apps/admin/src/components/admin/calendar-grid.tsx + week-calendar-grid.tsx — recurringWeeks: number replaced by recurrence: { startDate, endDate, daysOfWeek } | null in the onConfirm payload. Both callers now construct start_time from the dialog's chosen Start Date (not the click date) so 'shift the first occurrence forward' works. Both surface skipped_occurrences via a toast after submit.
Shared util: apps/admin/src/lib/recurring-bookings.ts — describeSkipReason + formatSkippedDate. One source of truth for the skipped-toast wording across calendar-grid and week-calendar-grid.
E2E: apps/admin/e2e/tc-116-recurring-credit-booking-atomic.e2e.ts rewritten — six atomicity scenarios preserved, inputs translated via recurrencePayloadFor(start, occurrences); skipped_occurrences shape assertions replace skipped_weeks.
E2E: apps/admin/e2e/tc-141-recurring-multi-day-date-range.e2e.ts (NEW) — five scenarios. (1) API happy 3 chips × 4 weeks → 12 occurrences. (2) Coach-availability skip on Wed → 8 created + 4 skipped with reason='coach_reserved'. (3) Mixed conflict + coach skip → 7 created + 5 skipped with two distinct reasons. (4) Validation matrix: recurring_weeks_removed / recurrence_fields_incomplete / recurrence_exceeds_max / recurrence_days_invalid (empty/oob/duplicate) / recurrence_end_date_invalid / no_matching_dates. (5) UI happy path — toggle, multi-chip, preview match, submit, DB row count assertion.
E2E migration: tc-075 + tc-078 (one line each — unrelated recurring uses) + tc-103 (recurring + players UI test) migrated to the new shape. No coverage lost.
Decisions: DEC-197 (90/180 cap), DEC-198 (replace recurring_weeks), DEC-199 (chips only, no preset dropdown), DEC-200 (skipped_occurrences shape), DEC-201 (chips win, click DoW is a hint), DEC-202 (coach-reservation pre-check via existing RPC), DEC-203 (rewrite TC-116, add TC-141), DEC-204 (DST-safe occurrence construction).
Resolves QA feedback A2 (specific-date availability) and A3 (unavailable override) plus the two boss requirements (date_range + monthly recurrence). Before this PR, `coach_schedules` only supported weekly recurrence on a fixed day_of_week — operators couldn't model 'Maria on Royal Padel May 25 only', 'every Tue–Fri this month', or 'Maria, 2pm–4pm off on May 25'. Now the editor's per-row dropdown picks one of Weekly / Monthly / Single date / Date range and the row's fields adapt; a new Unavailable Exceptions section adds whole-day or time-windowed overrides keyed by date. Schema rewrite (DEC-196): rename `coach_schedules` → `coach_availabilities`, drop `day_of_week` entirely (single source of truth in `recurrence_config.days_of_week` via in-migration backfill), add `recurrence_type` + `recurrence_config` jsonb with a COALESCE'd shape CHECK. New `availability_exceptions` table holds the A3 overrides (CHECK enforces both-set-or-both-null). Booking + lesson trigger rewritten to dispatch on local_date via `coach_availability_applies_on_date` and subtract exceptions per-date (whole-day zeros out, time-windowed range_agg + multirange subtraction). Overlap trigger replaces the EXCLUDE constraint with cross-type occurrence matching (per-type bounds — single_date = 1 occurrence, date_range = exact extent, weekly/monthly = 12-month cap; DEC-195). Friendly errors via translate-coach-error.ts. Public + admin consumers (availability, availability/range, coaches/[id], coaches/[id]/available-slots, lessons/available, calendar stripe) all moved to the new shape + helper. A6 stack on remote remains untouched per the boss directive.
Migration: supabase/migrations/20260512102224_coach_schedule_flex.sql — rename table, drop day_of_week + is_available, add recurrence_type + recurrence_config jsonb with COALESCE'd shape CHECK, backfill weekly rows into recurrence_config.days_of_week, new availability_exceptions table (whole-day OR time-windowed CHECK + unique(coach,facility,date,start_time)), helpers coach_availability_applies_on_date + coach_availability_dates, replaced check_booking_against_coach_reservations (applies_on_date + per-date exception subtraction), new coach_availabilities_check_overlap trigger (per-type occurrence materialization; computes time_range locally since GENERATED columns populate AFTER BEFORE triggers fire), RLS policy for availability_exceptions.
Public + admin consumers: /api/v1/facilities/[slug]/availability, /availability/range, /coaches/[id], /coaches/[id]/available-slots, /lessons/available, /api/admin/coaches/[id], /api/v1/facilities/[slug]/lesson-packages, calendar-grid.tsx — all moved to coach_availabilities + per-date appliesOnDate filter + per-coach exception subtraction (whole-day short-circuit + time-windowed range subtraction). Public response shape stays compatible for OpenRally/mobile.
E2E: tc-136..tc-140 — five UI-driven tests covering single_date, date_range, monthly, whole-day exception, and time-windowed exception. Each opens the editor through the Academy → Coaches dropdown menu, configures one recurrence/exception, saves, then asserts via /api/v1/.../availability that target dates flip to coach_reserved and control dates stay available. tc-132 picks up the rename in setup + cleanup.
Decisions: DEC-194 (exception granularity — both whole-day and time-windowed), DEC-195 (per-type bounds for overlap trigger materialization), DEC-196 (rename + drop day_of_week — single source of truth).
5 E2E tests
v0.23.14Shipped
CRM "Last Visit" includes POS orders + tickets (E1)
11 May
Resolves feedback E1 (HIGH, customer-trust). The CRM customer list's `last_visit` was the max of `bookings.start_time` only, so a customer who only ordered food at the bar showed 'Last visit: Never' — and a customer with ONLY POS activity didn't appear in the list at all (the userIdSet derivation only included bookings, matches, and program enrollments). Boss reported via Alba Garrido: 'I ordered food but it says I never visited.' Fix is read-side only: extend the per-customer last_visit computation in /api/admin/crm/customers, /api/admin/crm/customers/[id], and /api/admin/crm/segments/preview to scan pos_orders + facility_tickets in addition to bookings, taking the max across all three sources. facility_customers with a linked profile_id now also seeds the userIdSet so pure-POS customers appear in the list. The pos_orders / facility_tickets contribution uses the existing join shape (pos_orders.pos_customer_id → facility_customers.id → facility_customers.profile_id; facility_tickets.facility_customer_id same shape). bookings_count and total_spend still reflect bookings only — that field is correctly named. Computed at query time (no denormalized column, no triggers, no backfill) — see DEC-192. Pre-existing typo bug discovered + fixed: bookings.price_cents (column doesn't exist; the actual column is price_amount) silently returned 400 from PostgREST → bookingStatsRes.data was always null → bookings count, spend, AND booking-based last_visit were broken for every customer regardless of activity. Same typo lived in the customer-list, customer-detail, and segments-preview routes. Fix renames the SELECT to price_amount everywhere in those CRM routes; the API still returns the `price_cents` field name for UI back-compat (the field name is an API contract, not the DB column). Without this fix, the 'no regression for bookings' DoD on E1 would have held vacuously since the path was already broken — DEC-193 logs the discovery.
Customer list route: apps/admin/src/app/api/admin/crm/customers/route.ts adds a fourth source (facility_customers with non-null profile_id) to the userIdSet derivation so pure-POS customers show up, and extends lastVisitMap with pos_orders.created_at + facility_tickets.closed_at|opened_at — taking the max across {booking.start_time, pos_order.created_at, ticket.closed_at|opened_at} per profile. Sort + recent/inactive filters operate on the same richer value with no logic change. New batch queries are scoped via facility_customer_ids gathered in one earlier hop, then `.in()`'d to pos_orders / facility_tickets. Empty facility_customers short-circuits both queries to avoid an unnecessary round-trip.
Customer detail route: apps/admin/src/app/api/admin/crm/customers/[id]/route.ts renames the bookings-based variable to `latestBookingTime` and computes `lastVisit` afterwards by max-merging an unfiltered pos_orders.created_at query (no status filter — opening a tab is still a visit) and the already-fetched tickets' closed_at|opened_at. Existing posOrders fetch (filtered by status [completed, paid, closed] for spend display) is unchanged; the lastVisit max is a separate light query.
Segments preview route: apps/admin/src/app/api/admin/crm/segments/preview/route.ts adds pos_orders.created_at to its existing pos_orders fetch and a new facility_tickets fetch parallel to the others, and merges both into lastVisitMap. Keeps the EnrichedCustomer shape unchanged so segment filters on `last_visit` / `last_visit_days` see the richer value without code changes — POS-active customers are no longer misclassified as inactive by the 90-day cohort filter.
Pre-existing typo fix: bookings.price_cents → bookings.price_amount in /api/admin/crm/customers/route.ts (2 spots), /api/admin/crm/customers/[id]/route.ts (3 spots including the recentBookings mapping that keeps the `price_cents` field name in the API contract), and /api/admin/crm/segments/preview/route.ts (2 spots). Discovered while running the booking-only sanity assertion in tc-135 — PostgREST returned 400 'column bookings.price_cents does not exist' and bookingStatsRes silently fell through to `data: null`. Logged in DEC-193. Out of scope for this PR: same typo in apps/admin/src/app/api/admin/crm/customers/[id]/timeline/route.ts and crm/customers/[id]/page.tsx — those are separate API + UI surfaces; can be a follow-up.
List UI testids: apps/admin/src/app/[facility]/crm/customers/page.tsx adds `data-testid='customer-row'` + `data-customer-email` per row and `data-testid='customer-last-visit'` on the last-visit cell, so tc-135 can assert against stable selectors instead of Tailwind classes (per CLAUDE.md test-selector convention).
E2E: apps/admin/e2e/tc-135-last-visit-includes-pos.e2e.ts — 3 cases pinning the contract: (1) POS-only customer (facility_customers + paid pos_order + closed facility_ticket, no bookings) shows a real Last Visit timestamp in the list and is not 'Never'; (2) sanity: customer with only a bare facility_customers row (no orders, no tickets, no bookings) still shows 'Never' — only POS *activity* counts, not the bare row; (3) no-regression: customer with only a confirmed booking (no POS) shows a real date driven by the booking timestamp. Each case creates its own auth.user + customer + facility_customers in beforeAll, asserts via the rendered cell text (not just API), and cleans up in afterAll. Booking-only uses a far-future date (RUN_ID-derived offset) + a quiet 3:XX AM UTC slot to dodge idx_bookings_no_overlap.
DEC-192 logged — query-time computation over a denormalized customers.last_visit_at column + triggers (rejected alternative: denormalized column + 3 triggers on bookings/pos_orders/facility_tickets + one-time backfill is write-path complexity for a read-path problem at ~3000 customers per facility, with race-handling overhead).
DEC-193 logged — pre-existing bookings.price_cents typo discovered during repro; fixed in the 3 CRM routes touched by this PR (kept the `price_cents` field name in the API response so the customer-detail UI didn't move).
3 E2E tests
v0.23.13Shipped
"Paid with Package" badge for package-paid lessons (C2)
11 May
Resolves feedback C2 (HIGH). create_lesson_atomic debited the player's package via consume_package_session but never flipped bookings.payment_status to 'paid' — the booking-detail dialog and bookings-list then rendered the misleading orange "Payment Pending" badge for a booking the customer had already settled. Alba (Pad Thai Padel coach) reported this for two private lessons paid from her 5-session package. Fix: the RPC now updates payment_status to 'paid' inside the same transaction for every booking whose payment_method is 'package' (so a failed consumption rolls back the flip together with the inserts). The badge UI on both surfaces derives the new "Paid with Package" label from (payment_status='paid', payment_method='package') — no new enum value, no migration of the payment_status CHECK constraint. Existing bookings stuck on 'pending' get the same flip via a one-shot backfill UPDATE in the same migration file. The admin /api/v1/lessons/[id]/confirm route (used by the web public-booking flow when paying with a package on a held lesson) also now sets payment_status='paid' on confirm, so the same regression doesn't recur outside the RPC.
Migration: supabase/migrations/20260511065715_c2_package_paid_status.sql replaces create_lesson_atomic with a version that flips bookings.payment_status to 'paid' for every package-paid booking after consumption succeeds, plus a strict backfill UPDATE (lesson-typed + payment_method='package' + status pending/null + EXISTS a matching lesson_players.payment_method='package') in the same transaction
Admin route: apps/admin/src/app/api/v1/lessons/[id]/confirm/route.ts sets payment_status='paid' on the booking update for both Stripe-verified and package-consumed paths (the route exits early before the update if either verification fails)
Decision: DEC-189 logged — don't extend the payment_status CHECK constraint with a 'paid_with_package' enum value; the badge is a 2-axis derivation, payment_method already captures the "how"
E2E: apps/admin/e2e/tc-134-package-paid-badge.e2e.ts — 4 serial cases: (1) admin books a private lesson via the calendar lesson dialog with the "Use package session" checkbox enabled, (2) DB asserts session debited + bookings.payment_status='paid' + payment_method='package', (3) booking-detail dialog renders "Paid with Package" and survives reload, (4) bookings-list row renders "Paid with Package" and survives reload
1 E2E tests
v0.23.12Shipped
Discount Rules — Multiple Targets per Rule (E3)
11 May
The discount-rule editor now lets a single rule target N items at once instead of forcing a one-target dropdown. Operators can author 'Staff group — 50% off Drinks AND Food' as a single rule (boss directive via Loom, 2026-05-11). Schema (crm_discount_rule_targets is a many-to-one join) and the resolver SQL already supported multi-target — only the UI's cardinality changed. Side fix: the resolver's eligibility query was rewritten to use EXISTS instead of a JOIN against crm_discount_rule_targets, eliminating a latent double-count bug that triggered when a single line matched multiple targets of the same rule (e.g. an `[any, category:Drinks]` rule on a Drinks line). API tightened to reject empty targets[] arrays with a 400 instead of silently defaulting to 'any'.
Editor refactor: apps/admin/src/components/admin/discount-rule-editor.tsx replaces the single-target dropdown with a list-of-rows + per-row dropdown + per-row Trash + 'Add target' button. Each row carries its own target_type/target_id/target_value. Soft warning when an 'any' row is mixed with specifics. Validates non-empty targets on save
Rules list: apps/admin/src/app/[facility]/crm/discount-rules/page.tsx renders one chip per persisted target (type-labeled — full name resolution would need an extra fetch, deferred) so multi-target rules are visible without opening the editor
API: POST /api/admin/crm/discount-rules and PUT /api/admin/crm/discount-rules/[id] reject empty targets[] with 400 instead of silently inserting a no-op 'any' row. POST validates BEFORE the rule insert (no insert-then-rollback dance)
Resolver fix: supabase/migrations/20260511054837_fix_resolve_discounts_target_dedup.sql replaces the JOIN against crm_discount_rule_targets with an EXISTS subquery so a rule with multiple matching targets appears once in v_eligible (not once per matching target). Idempotent CREATE OR REPLACE FUNCTION; signature unchanged. Single-target rules see identical output
E2E: apps/admin/e2e/tc-133-discount-multi-target.e2e.ts — (a) UI flow: Staff group + 50% + 2 category targets via Add target → assert DB has 2 rows, list renders 2 chips, edit dialog shows both rows, preview discounts both Drinks & Food and skips unrelated; (b) double-count regression: rule with [(any), (category, X)] on a Drinks line lands ONE 50% discount, not two; (c) API rejects empty targets[]
3 E2E tests
v0.23.11Shipped
Per-Court Coach Reservation (A5)
8 May
Coach availability becomes a per-court reservation that hard-blocks regular customer bookings AND lessons by other coaches on the reserved court+time. Strict-Calendly semantics for lesson-vs-availability fit: a lesson must fall entirely within the union of the lesson coach's reservations on the courts it uses; a coach with zero reservations cannot have any lessons booked. Schema enforces invariants at the DB layer via an EXCLUDE constraint (no two coaches reserve the same court+time) plus three triggers calling a shared SQL function (`check_booking_against_coach_reservations`). Multi-court chip selector in the schedule editor; striped overlay on the admin calendar with a per-coach hue and toggle; public availability endpoint returns `coach_reserved` for affected slots so consumers/OpenRally see the court as unavailable. Friendly error translator at apps/admin/src/lib/translate-coach-error.ts wires DB errors into operator-readable messages on bookings, lessons, and schedule editor save paths.
Migration: supabase/migrations/20260508164245_coach_court_reservation.sql adds court_id NOT NULL, drops is_available, adds time_range generated column, the no_court_overlap EXCLUDE constraint, the shared validator + 3 triggers (bookings, lesson_courts, lessons), AND rewrites hold_lesson_slot + create_lesson_with_bookings RPCs to set booking_type='lesson' on the bookings they insert (otherwise the new trigger would (correctly) reject those holds as court_reserved_for_coach)
Schedule editor: multi-court chip selector per row, grouped load (rows sharing day+time merge into one editor row), friendly error translation in the API route
Calendar: striped reservation overlay with per-coach UUID-hash hue, coach name label, `Coach availability` toggle in the header
Public availability: GET /api/v1/facilities/[slug]/availability now emits `status: "coach_reserved"` for slots overlapping any coach reservation
Available-slots: /api/v1/facilities/[slug]/coaches/[id]/available-slots iterates merged coach reservation windows, and only courts the coach has reserved appear in `available_courts`
lessons/available endpoint: same per-court strict-Calendly filter applied to coach-paged availability lookups
Friendly error translator: apps/admin/src/lib/translate-coach-error.ts wired into /api/admin/bookings, /api/admin/lessons, /api/admin/lessons/[id], /api/admin/coaches/[id]/schedule
Shared validator: packages/common/src/coach-schedule.ts updated to drop is_available and treat per-day rows as a union (DB trigger does the strict per-court enforcement)
Types regenerated: packages/database/src/database.types.ts now requires court_id on coach_schedules Insert and exposes the new time_range generated column
Seed: supabase/seed.sql adds coach reservations for the demo coach on every court so `supabase db reset` succeeds end-to-end with the new triggers
E2E: apps/admin/e2e/tc-132-coach-court-reservation.e2e.ts (5 cases — block court booking on reserved court, allow on other court, reject lesson on unreserved court, public availability marks coach_reserved, calendar stripe + toggle) + tc-037 updated for multi-court chip + setup-booking-data.ts and 8 other tests updated for the new schema (court_id required, is_available dropped)
5 E2E tests
v0.23.7Shipped
Atomic Credit Booking — BUG-021
29 Apr
Resolves BUG-021. POST /api/admin/bookings (single, payment_method=credits, price>0) now deducts credits and inserts the booking inside one Postgres transaction via the new public.create_booking_with_credits RPC — if the insert fails, the deduction rolls back automatically. The reference_id on the new club_credit_transactions row is set inside the same call, so the previous race-prone post-insert UPDATE is gone. adjust_club_credits is intentionally untouched (still shared with refund / top-up / match-payment paths). Recurring credit-paid bookings have a separate, pre-existing bug (filed as BUG-039) — they never deducted credits in the first place; not addressed here.
RPC validates `club_credits.balance_amount >= p_price_amount` up front (adjust_club_credits clamps a negative result to 0, so we cannot rely on it for sufficiency)
RPC inserts the booking, then calls adjust_club_credits with p_reference_id := new booking id, both inside the function's implicit transaction
Route refactor: apps/admin/src/app/api/admin/bookings/route.ts replaces the L181–L240 deduct→insert→reference-update sequence with a single supabase.rpc('create_booking_with_credits', ...) call. Non-credits + free-on-credits paths use the direct insert unchanged
Insufficient-credits surfaces as HTTP 400 with a clean message (`Insufficient club credits: have X, need Y`); other RPC errors → 500
Types regenerated: packages/database/src/database.types.ts now exposes create_booking_with_credits to TypeScript callers
E2E: apps/admin/e2e/tc-115-credit-booking-atomic.e2e.ts — happy path, FK-rollback path (phantom court_id passes the conflict check but fails inside the RPC), insufficient credits, concurrent bookings reference-id correctness
Docs: BUG-021 (credit-deduction) marked resolved with resolvedIn note; new BUG-039 filed for the separate recurring-credits-no-deduction issue; DEC-177 logs the wrap-don't-modify-shared-RPC choice
4 E2E tests
v0.23.6Shipped
Known-Issues Reconciliation — BUG-018
29 Apr
Documentation-only patch. BUG-018 (operating hours not editable from facility settings UI) was filed during Pad Thai Padel onboarding on 2026-03-23 when facility-settings-form.tsx had no operating_hours fields. The form has since been extended with a full per-day editor — state (L100), load (L161-162), open/close pickers (L745, L757), save (L232) — but the known-issues entry was never updated. This entry marks BUG-018 as resolved and adds a top-of-file comment so future scans don't re-discover it as open.
Docs: Mark BUG-018 status: resolved + resolvedIn note in apps/docs/src/data/known-issues.ts
Docs: Add BUG-018 RESOLVED line to knownIssuesMeta comment block
0
v0.23.5Shipped
Email Lookup Case Normalisation
29 Apr
Resolves BUG-037. Supabase Auth stores emails lowercase, but ten admin endpoints looked up customers via `.eq("email", input)` with no normalisation — admins typing `Foo@Example.com` for a customer who signed up as `foo@example.com` got false 404s (and, on write paths, attempts to create duplicate auth users). Fix is mechanical: each endpoint now trims + lowercases the input once at the top of the handler, mirroring `findOrCreateUserByEmail()` in apps/admin/src/lib/find-or-create-user.ts. No DB or schema changes — the column was already lowercase. apps/admin/src/app/api/admin/players/search/route.ts already normalised at L8 and was left untouched.
API: v1/bookings/hold — guest_email normalised before customers lookup
API: admin/matches POST — organizer_email body field normalised
API: admin/programs/[id]/enrollments POST — user_email body field normalised
API: admin/team POST — email body field normalised before existing-customer lookup and createUser
API: admin/club-credits GET + POST — user_email normalised
API: admin/club-credits/topup POST — user_email body field normalised
E2E: TC-112 gains CT-L2-02c — hits /api/admin/credit-tiers/user-tier with mixed-case email and asserts 200 + tier resolves
1 E2E tests
v0.23.4Shipped
Lesson Per-Student Check-In
27 Apr
Resolves BUG-035. Lesson bookings now support per-student check-in across both admin and terminal — clicking 'Check In' on a 3-student group lesson stamps each student individually, opens 3 POS tickets (one per student, priced from each student's own payment_method × class price_per_pax), and flips bookings.checked_in only once everyone is in. Mirrors the court per-player flow (DEC-173/174); diverges where the lesson data model forces it. DEC-176.
Schema: lesson_players.checked_in_at column added; lesson_id is FK-indexed already so no new index needed
Shared: checkInLessonStudent in packages/common/src/check-in.ts — sibling of checkInPlayer, reuses derivePricing/resolveFacilityCustomer/buildBookingLineItem helpers verbatim
Admin: New PATCH /api/admin/lessons/[id]/students endpoint — loads lesson + class price + primary lesson_courts.booking_id, delegates to checkInLessonStudent, maps ALREADY_CHECKED_IN→409
Admin: enrich-bookings.ts now returns lessonStudents[] (with checked_in_at + ticket_number cross-referenced via lesson's full booking_id set) for booking_type='lesson' rows
Admin UI: BookingDetailDialog renders 'Students (N)' section with per-row Check In button + ticket badge; booking-level button branches three ways (hasPlayers / hasStudents / solo)
Pricing fix during port: pending student → pricePerPaxCents on their own ticket (not 0). Court's organizer-only model doesn't apply since lessons charge per-student per DEC-147–149
Stable lessonStudents ordering (primary first, then by email) in both admin enrichment and terminal query — keeps lesson-student-{idx} testids deterministic across refetches
E2E: TC-113 (admin) + TC-114 (terminal) — same fixture, both surfaces; playwright config gains a webServer entry for terminal (port 3007), test-helpers gains TERMINAL_URL
Schema sync: rebased local migrations on prod via /sync-supabase skill; the prior local set had a duplicate-policy migration that broke 'supabase db reset' on a fresh clone
2 E2E tests
v0.23.4Shipped
Admin Dark-Mode Fix
14 May
Fix admin dark-mode-on-refresh: app no longer silently follows OS preference; theme toggle uses resolvedTheme. The toggle UI only exposes light/dark, so the ThemeProvider's `enableSystem` is now off — `defaultTheme="light"` actually wins on first paint regardless of OS preference. The sidebar + standalone toggle now read `resolvedTheme` (what's actually rendered) instead of `theme` (the chosen setting), so the first click never looks like a no-op. The sidebar's `suppressHydrationWarning` band-aids that were masking the underlying bug are removed in favour of a proper `mounted` gate. DEC-212.
Admin: ThemeProvider now sets enableSystem={false} so defaultTheme="light" actually wins regardless of OS preference (apps/admin/src/app/layout.tsx)
Admin: ThemeToggle button reads resolvedTheme instead of theme — fixes the first-click no-op when the user has never explicitly chosen a mode (apps/admin/src/components/ui/theme-toggle.tsx)
E2E: TC-144 simulates a dark-OS browser context, asserts admin defaults to light, toggles to dark, reloads, asserts persistence; back to light + reload again
1 E2E tests
v0.23.17Shipped
Week-view all-courts per-court swim lanes
14 May
Fix admin week-view all-courts booking: previously, booking any one court at a given time silently blocked the same slot on every other court because the day-cell aggregated all courts into a single shared lane — once one court had a booking the empty `+` click target disappeared, and even before any booking the click routed to `courts[0]` regardless of operator intent. This patch refactors each day-cell in all-courts mode into one independently-clickable swim-lane per court (industry-standard pattern — Google Calendar / Calendly / Acuity / OpenTable). Each lane has its own empty `+` button bound to its specific court via a new `handleEmptySlotClick(d, tick, court)` signature; the `courts[0]` fallback is deleted entirely. The day-column min-width scales to `max(110px, 36px × courts.length)` and the day-header gains a chips strip naming each court aligned to the lanes below. 2hr bookings span vertically within their own court's lane only — TC-142 (spanning booking + tail click) is preserved. Single-court tabs render exactly as before, zero visual regression. DEC-221.
Admin: apps/admin/src/components/admin/week-calendar-grid.tsx — per-court swim lanes inside each day-cell when selectedCourt === 'all'; each lane is its own relative container, gets its own empty `+` button, and renders only its own bookings
Admin: `handleEmptySlotClick(d, tick, court)` takes the specific court explicitly; the `courts[0]` fallback is deleted — every click path now carries its court
Admin: New helpers `getBookingsForCellAndCourt` + `isSlotOccupiedForCourt` replace the previous aggregated filters; single-court mode passes the selected court id, all-courts mode passes per-lane court id
Admin: Day-header chips strip — small horizontal row of court-name chips aligned to the lanes below; visible only in all-courts mode; carries `data-testid='week-court-chips-{date}'`
Admin: data-testid hooks on every lane (`week-lane-{date}-{tick}-{courtId}`), every empty `+` button (`week-empty-{date}-{tick}-{courtId}`), and every booking card (`week-booking-{id}`) — assertable per-lane interaction surface
E2E: apps/admin/e2e/tc-145-week-view-all-courts-per-lane.e2e.ts — three courts, books each one in turn at the same 10am slot via its own lane `+` button, asserts the other lanes' `+` buttons remain visible between bookings (the regression fix), asserts DB has 3 distinct bookings, one per court
DEC-221 logged with full alternatives + rationale + counter-arguments (dialog court-picker, overlay `+`, modal-pick fallback — all rejected because they don't address the missing-click-target after the first booking)
1 E2E tests
v0.23.3Shipped
Bookings List Cleanup
24 Apr
Admin bookings list tightened per CEO feedback. Coach + Match columns are gone, replaced by a single Type column (Court / Coaching / Event badge). New Created On column (sortable) becomes the default sort so the row you just booked sits on top. Date/Time column renamed to Booking Time for clarity. Add to Calendar button removed from the booking detail dialog (confusing, unused) and its orphaned lib/ics.ts deleted. Date range filter inputs widened so the native picker content no longer overflows. UI-only — no DB, API, edge-function, or OpenRally changes. DEC-175.
Admin: Coach + Match columns replaced by a single Type column using existing BookingTypeBadge (Court / Coaching / Event)
Admin: new Created On column (sortable on created_at); default sort flipped to created_at desc
Admin: Date/Time column renamed to Booking Time (still sortable on start_time)
Admin: Add to Calendar button removed from booking detail dialog; orphaned apps/admin/src/lib/ics.ts deleted
Admin: Date range filter inputs widened (w-36 → w-48) to fit native date picker content + removed dead placeholder props
E2E: tc-074 assertions updated for new columns; recording flow adds a reload between Court/Coaching clicks to guarantee filter reset; 8/8 green
0
v0.23.2Shipped
Check-In Consolidation
24 Apr
Resolves the tech debt called out in DEC-173 (v0.23.1). Admin and terminal now share a single source of truth for check-in logic via packages/common/src/check-in.ts. Both surfaces call the same checkInBooking / checkInPlayer functions; the duplicated inline code is gone. Terminal also picks up three defensive hardening checks (previously admin-only): full-booking idempotency, per-player ALREADY_CHECKED_IN precheck, and stricter rollback on per-player ticket failure. No happy-path UI changes on either surface.
Shared module: packages/common/src/check-in.ts with checkInBooking() + checkInPlayer() as pure functions that take a Supabase client + findOrCreateProfile injection
Admin PATCH /api/admin/bookings (full-booking check-in) + PATCH /api/admin/bookings/[id]/players (per-player) delegate to the shared module; HTTP status codes and response shapes preserved
Terminal useCheckInBooking + useCheckInPlayer mutation hooks delegate to the shared module; react-query wiring, toasts, invalidations unchanged
packages/common/src/datetime.ts: new formatTimeTz(date, tz) helper (was duplicated in both callers)
apps/terminal/package.json: declare @setpoint/common + @setpoint/database workspace deps (fixes a pre-existing undeclared import of @setpoint/common/timezones)
Admin's Check-In button now produces the same DB end state as the terminal's — populated POS ticket(s) ready for checkout. Previously admin created an empty placeholder ticket with no cart line item, forcing staff to re-enter the booking at the till. Per-player check-in (booking_players.checked_in_at + per-player tickets with organizer-vs-other split pricing) is also supported from the admin booking detail, matching terminal's 'Check In All' UX.
Admin: PATCH /api/admin/bookings now resolves facility_customer (profile RPC → email + auth-account creation → profile-less fallback), allocates ticket_number via next_ticket_number RPC, and writes facility_tickets with a populated cart_json booking line item (isBooking, isPrePaid/isUnpaid from payment_method, bookingRef, bookingDisplayPriceCents)
Admin: New PATCH /api/admin/bookings/[id]/players with {player_id, checked_in:true} mirrors terminal useCheckInPlayer — flips booking_players.checked_in_at, writes a per-player ticket (organizer pays the court fee line, non-organizers 0), flips bookings.checked_in=true only once every player is in, rolls back on failure
Admin: enrich-bookings.ts returns checked_in_at + ticket_number per player (cross-referenced to facility_tickets by ticket_name)
Admin UI: Per-player rows render either a Check-In button or a green timestamp + ticket number badge; booking-level button becomes 'Check In All (N)' when players exist and hides once everyone is checked in
Admin UI: Optimistic patch of selectedBooking on per-player check-in so the dialog reflects state instantly during Check In All loops
Demo: .demo/ pipeline (scripts/tts.sh, narration.md, seed-demo.mjs, record-demo.mjs, demo-guide.md) produces a narrated video of the admin→terminal handoff; recording doubles as the functional E2E test
Bidirectional payment-status propagation between Setpoint and any OpenRally consumer (TPS today, channel managers / aggregators / third-party booking apps in the future). OpenRally protocol bumped to v1.1 with a vendor-neutral `payment` block on bookings (OTA + Stripe Connect lineage). Closes the 'every OpenRally booking shows Payment Pending in admin' gap.
Migration: external_payment_ref + external_payment_method_type columns on bookings
Migration: payment_method enum extended with 'external'; consistency CHECK enforces ref+method_type when method='external'
Fulfilment: Dine In / Takeout / Delivery / Curbside
Payment: card, cash, gift card, other
pos-v0.6Shipped
POS Layout
Feb
Drag-and-drop tile grid layout editor per catalogue — category tabs, discount tiles, per-catalogue independent layouts persisted to DB
POS layout edit mode toggle
Tile grid — drag, resize, reorder
Category tab tiles
Discount tiles — one-tap apply
Per-catalogue independent layouts
Layout persisted to database
pos-v0.7Shipped
Orders & Transactions
Feb
Orders list with 6 statuses, order detail modal, transactions grouped by date with payment method filters and metrics
Orders list — filter by status
6 statuses: draft → voided
Order detail modal + full breakdown
Search by order ID or customer name
Transactions list — grouped by date
Transaction filters — method + status
Metrics — count, collected, net sales
pos-v0.8Shipped
Inventory
Feb
Full inventory suite — stock overview, low-stock alerts, stock counts, adjustment history, purchase orders, vendor management, pending restocks
Stock overview + low-stock alerts
Per-item inventory enable/disable
Stock qty + low-stock threshold
Barcode / SKU + cost price fields
Stock counts + reconciliation
Adjustment history — full audit log
Purchase orders + vendor management
Pending restocks — open PO view
pos-v0.9Shipped
Customers & Gift Cards
Feb
Customer directory with notes and order history, gift card issuance with balance tracking, deactivation and expiry support
Customer directory — name, email, phone
Notes — allergies, preferences
Search by name, email, or phone
Order history preserved on deletion
Gift card issuance — code + balance
Balance auto-deducted on redemption
Card deactivation + expiry support
Aggregate metrics — count + total balance
pos-v1.0Shipped
Team, Reports & Settings
Feb
RBAC with 11 permission scopes, sales summary reports with date range presets, multi-location settings with timezone and currency
Team members CRUD + active/inactive toggle
11 permission scopes (RBAC)
Multi-role assignment per member
Sales summary — count, net, AOV
Date range presets — Today to Month
Gross / discounts / net / tax / tips breakdown
Business profile — brand + currency
Multi-location — address, timezone, currency
Service charges + dashboard KPIs
pos-v1.1Shipped
Type Restrictions
Mar
Item type locked to catalogue type at creation — food/physical/service enforcement, hidden type picker, sidebar cleanup, product docs
Item type locked to catalogue type permanently
Food catalogue → food items only
Physical catalogue → product items only
Service catalogue → service items only
Item type picker hidden (single type shown)
Static non-interactive type display
Sidebar nav cleanup
Product docs at /v1-1 + /v1-1/mvp
pos-v1.2Shipped
Item & Category Discounts
16 Mar
Pre-created discount presets with validity dates, attachable to individual items or categories — auto-apply at checkout with highest-value priority, strikethrough pricing in cart, staff can remove per line
Cart line — strikethrough original price + discounted price + label
Staff can remove auto-applied discount per cart line (× button)
apply_mode — auto-apply (rule-based) vs shortcut tile (manual toggle)
Full-screen discount modal with inline item/category assignment
Category images + category CRUD with discount assignments
pos-v1.3Shipped
Customer Groups & Discount Group Variations
21 Mar
Per-customer-group discount overrides — staff select a customer at the terminal and group-specific rates apply automatically. Includes full group CRUD, bidirectional membership management, and per-group rate overrides on any discount preset.
pos_discount_group_variations migration — per-group type+value override with RLS
Customer Groups CRUD — create, rename, delete groups from Customers > Groups tab
Group detail sheet — add/remove members with live customer search
Customer detail page — Groups card with add/remove from any group
Discount modal — Customer group pricing section with per-group rate overrides
Best discount wins — highest-value group variation selected when customer is in multiple qualifying groups
CustomerSelector in cart sidebar — search and select/clear customer
Terminal loads customer group IDs + builds groupVariationMap on customer selection
Auto-apply discounts respect group override (single-variation, dialog, variant picker)
Shortcut discount tiles respect group override when customer is selected
Tile badge updates to reflect group override value
Base discount applies when no customer selected or no matching group variation
Cart item modal — view and manage discounts on items already in the cart
pos-v1.4Shipped
Thailand VAT
Mar
Thai VAT (7%) calculated at checkout, legally compliant tax invoices with sequential numbering, facility tax profile setup, and service-charge-inclusive VAT base per Thai Revenue Code.
Tax calculation engine — exclusive and inclusive VAT, service charge included in base
pos_facility_tax_profile table — store club's government business number, registered name, branch
Custom discount at checkout — cashier enters % or fixed $ amount, updates final price live
pos-v1.5Shipped
Indonesia PPN
Mar
Indonesia PPN (11%) support added alongside Thailand VAT. Tax Profile settings now includes a country selector — operators pick Thailand or Indonesia and all labels, TIN format, and receipt headers adjust automatically.
tax_country column on pos_facility_tax_profile — TH or ID
Tax Profile country selector — Thailand (VAT 7%) or Indonesia (PPN 11%)
Country-aware labels — NPWP vs Tax ID, Cabang vs Branch, DJP vs Revenue Department
Receipt header switches — FAKTUR PAJAK for Indonesia, TAX INVOICE (ใบกำกับภาษี) for Thailand
Receipt TIN label — NPWP for Indonesia, Tax ID for Thailand
pos-v1.6Shipped
Reports & Tax Reliability
Aug
First phase of a Square-benchmark reporting overhaul: real math on the Sales Taxes report (was half hardcoded placeholders), CSV export, and channel/business_date captured on every order so future reports can be built on dimensions that actually exist.
Sales Taxes report — real taxable-base math (reverse-derived per tax line, not a guessed order-item sum)
Sales Taxes report — taxable/non-taxable service charge split from the real is_taxable flag
Sales Taxes report — CSV export
pos_orders.channel — pos vs terminal, CHECK-constrained, captured at checkout
pos_orders.business_date — reporting day computed from location timezone at write time, not derived later from the browser's clock
businessDateForTimezone() in packages/common/datetime — safe fallback to UTC on missing/invalid timezone
Wired into both apps' checkout + the two async payment-link completion paths (webhook, cron fallback)
Single-day report windows (Sales Taxes, Discounts, Payment Methods, Reconciliation, Item Sales) attribute orders to the venue's calendar day, not the browser's — reportingDayForOffset()
Multi-day preset ranges (Last 7/30 days, This/Last month, This week/year, Last 52 weeks) across both apps' report pickers rebuilt on facility-local date-string arithmetic instead of browser-local Date methods — buildPreset()/computePeriod()/getDateRange() plus new shiftDateStr()/monthStartStr()/monthEndStr()/facilityDateRangeBoundaries() shared helpers
Sales dashboard, Item Sales, and Cash Drawers date pickers re-derive their default range once the venue's real timezone loads, instead of staying anchored to a UTC-fallback guess for the session
Category Sales peak-hours bucketing now reads the venue's local hour, not the viewer's browser hour
pos-v1.7Shipped
Atomic Stock Adjustments
Aug
The #1 inventory risk flagged by architecture review: stock_qty was decremented via client-side read-modify-write (SELECT current qty, compute in JS, UPDATE) across every stock-touching flow in both apps, which loses updates under concurrent sales of the same item and never wrote a pos_stock_adjustments audit row for sales, so drift was invisible. Oversells were silently clamped to zero, hiding that they happened. New pos_apply_stock_delta(p_target_table, p_target_id, p_delta, ...) Postgres RPC applies every stock change atomically (row lock serializes concurrent callers — no lost update) and writes the audit row in the same transaction; pos_stock_adjustments is now append-only at the DB layer. Also fixed along the way: ticket-cart saves were double-decrementing stock on top of checkout's own decrement for the same sale (removed — only checkout decrements now, a saved cart is a draft); a silent stock-skip when the checkout-time catalog lookup errored; purchase-order receiving could desync qty_received from actual stock on a partial failure; a missing facility check on variation-level adjustments; 14 leftover out-of-stock UI checks across both apps still using stock_qty === 0 instead of <= 0 (two of which — the POS cart tile — let an oversold item stay addable to the cart indefinitely); and the dashboard's own KPI numbers had the same silent-truncation bug already fixed in the reports pages by pos-v1.6, just in a different file.
packages/common/src/stock.ts — centralizes the item vs. variation vs. variant branching + RPC call shape, replacing 7 near-duplicate implementations across both apps
Checkout stock decrement (use-order-mutations.ts, both apps) — atomic + audited, order-linked; catalog-lookup and per-line failures now surfaced via toast instead of silently skipped
Ticket-cart save no longer decrements stock (use-ticket-mutations.ts, terminal) — fixes a real double-count against checkout's own decrement for the same sale
Manual stock adjustments + edit/correction flow (use-stock-adjustment-mutations.ts, both apps) — through the RPC; edits now insert a correcting row instead of mutating history in place
Purchase-order receiving (use-purchase-order-mutations.ts, both apps) — stock applied before qty_received advances, so a failure can no longer leave the PO showing received with stock unchanged
14 out-of-stock UI checks migrated from === 0 to <= 0 across both apps (items list, inventory page, menu detail, POS-layout picker, POS cart tile)
Dashboard KPIs (use-dashboard-stats.ts, both apps) — same pagination fix pos-v1.6 applied to the reports pages, applied here to the dashboard's own numbers
Playwright e2e coverage for the above
pos-v1.8Shipped
Daily Report Item Sales
Aug
Second phase of the Square-benchmark reporting overhaul (pos-v1.6): the daily sales report email gained an Item Sales section — total items sold, total item-sales revenue, the top-selling category, and the top 10 items by revenue. pos_daily_sales_summary's item grouping and category resolution deliberately mirror the same logic the Item Sales and Category Sales report pages already use (useFullReport's item_name grouping, resolve-category-names.ts's join path) rather than an independently-derived source of truth, so the emailed numbers can't quietly disagree with what an owner sees on those pages for the same day.
supabase/migrations — pos_daily_sales_summary extended with total_items_sold, total_item_sales, top_category, top_items (top 10 by revenue); single materialized CTE scan over pos_order_items shared by both rollups
send-daily-sales-report email template — Item Sales section (totals + top category + top-10 items table), HTML-escaped item/category names, renders nothing on a zero-sales day instead of an empty table
Playwright e2e coverage for the above
pos-v1.9Shipped
Report Date-Range Filtering
Aug
Phase 5 of the reporting overhaul: every report page (13, not just the 6 originally flagged) now shares one DateRangePicker component with a genuine custom from/to range, not just fixed presets — most reports previously only let staff page one calendar day at a time via a chevron, or offered at most 6 fixed presets with no way to pick a specific past day. Extracted a shared useReportDateRange hook that also closed a real bug: query boundaries silently stayed pinned to a stale location's timezone after switching locations, since the resync-on-timezone-change effect (duplicated across every page) permanently stopped firing after the first user interaction.
date-range-picker.tsx — shared DateRangePicker + useReportDateRange hook now backing all 13 report pages; added last52weeks/thisYear presets, rangeGranularity() chart-bucketing helper
sales-taxes, discounts, gift-card-liability — fixed .lt -> .lte query boundary to match the picker's inclusive-end range shape
activity-log — real date filtering added where none existed before (was an unfiltered last-200-entries feed)
Facility-timezone-safe date math throughout — fixed a systemic local-vs-UTC-Date-getter bug causing week/month chart bucketing to silently disagree with the facility's calendar for a viewer in a different timezone, found in 4 separate spots including one predating this branch
Playwright e2e coverage for the above
Standalone Service Charge Summary report
pos-v1.10Shipped
Drawer Session Integrity
Sep
Hardening after the Pad Thai Padel 2026-08-29 incident: a transient query failure was rendered as 'no active session' / a ฿0 balance, so staff started a second drawer session on top of the still-open one — forking the drawer and resetting the visible balance. One open session per facility is now enforced at the schema layer, start-session rejoins instead of forks, and every drawer surface distinguishes 'can't load' from 'zero'. See DEC-317.
Migration 20260901040059 — snapshot open sessions (service-role only), auto-close superseded ones (NULL amounts + marker note), partial unique index: one open session per facility
startDrawerSession rejoins an existing open session (audit-logged; entered float NOT applied to avoid double-count) and handles the unique-index race; opening-float ledger insert is rolled back on failure
useActiveDrawerSession throws on error so React Query keeps last-known data; endDrawerSession refuses to close on a failed transactions fetch
Terminal gate + drawer page: explicit can't-reach-server retry states instead of the start form or a computed ฿0; End Session disabled while transactions are unloaded; Sales/History tabs get matching error states
lib/drawer.ts DRAWER_TXN_SIGN — single source for drawer balance math shared by UI and endDrawerSession
pos-v1.11Shipped
Inventory Defaults, Cost Price, Facility Switcher
Sep
Four day-to-day POS admin fixes from Alba: the Items list now opens on Active (was All statuses) and the Category filter gains an Uncategorized entry with a live count so orphaned items can be found and bulk-assigned; Cost price is now item-level and stays visible even when an item has pricing variations (it was silently hidden and stale before); Taxes & charges moved from the bottom of the item form to directly under Cost price, forming one pricing block with Price; and POS admin — POS admin only, not Terminal — gets a top-left sidebar facility switcher for multi-facility accounts, matching Setpoint admin's, so switching no longer requires signing out. See DEC-319, DEC-320.
Items list: Status filter defaults to Active; "Clear filters" resets to Active, not All statuses
Items list: Category filter gains an Uncategorized entry with a live item count (UNCATEGORIZED_CATEGORY_ID already existed in the query layer, just wasn't exposed on this page)
Item modal: Cost price is item-level and unconditionally visible (removed the isSingleVariation gate that hid it whenever an item had pricing variations)
Item modal: Taxes & charges moved directly under Cost price, before Description — reads as one pricing block with Price
POS admin sidebar facility switcher (multi-facility accounts only) — new FacilitySwitcher component, shared setFacilityCookie() extracted from the post-login picker's selectFacility action, new non-redirecting switchFacility() action, static-route allow-list (resolveSwitchLanding) keeps the operator on the same section after a switch
Playwright e2e coverage: apps/pos/e2e/inventory-defaults-cost.spec.ts (4 tests), apps/pos/e2e/facility-switcher.spec.ts (4 tests), apps/terminal/e2e/no-facility-switcher.spec.ts (1 test) confirming Terminal deliberately has no switcher
9 E2E tests
pos-v1.12Shipped
Checkout Never Decremented Stock (root cause + full fix)
Sep
Checkout had never decremented stock on any completed sale (till, POS admin, or Stripe payment link) since apps/terminal shipped in April. Root cause: every pre-checkout catalog-items lookup used a bare `item_variations(id)` embed, which is not a real PostgREST relationship (the table is pos_item_variations) — every checkout got PGRST200, silently swallowed into a toast + an empty stock-target list. Confirmed in production: 524 completed terminal orders in a 10-day sample, zero reason='sale' rows in pos_stock_adjustments. A second, independent bug surfaced once the embed was fixed: single-variation items were decremented on the pos_catalog_items mirror column instead of the pos_item_variations row every other screen reads. Fixed at all 4 call sites (terminal till checkout, POS-admin checkout, the payment-link cron, the Stripe webhook), centralized in packages/common/src/stock.ts. A code-review pass on the fix itself caught a third bug before merge: the new resolver never checked a variation's is_active flag, so a variation deactivated between add-to-cart and checkout would still be decremented while the new item-level mirror trigger excludes inactive variations from its sum — closed by gating on is_active in the same PR. New migration keeps pos_catalog_items.stock_qty as a live trigger-maintained mirror of its active stock_count variations for the back-office screens that still read the item-level value. A known accepted trade-off: the mirror-sync trigger adds checkout-latency lock contention when two registers concurrently sell different variations of the same multi-variation item (bounded blocking, not a deadlock or data-loss risk) — filed as a follow-up for an async/statement-level redesign rather than rushed into this fix. See PR #1079.
Root cause: STOCK_CATALOG_ITEMS_SELECT aliases the embed (item_variations:pos_item_variations) at all 4 call sites, moved into packages/common/src/stock.ts so it can't drift per-app again
resolveSaleStockTargets always targets the sold pos_item_variations row, only when that variation is itself tracking_type='stock_count'
Code-review fix (pre-merge): resolveSaleStockTargets/holdsCountedStock also gate on is_active, matching trg_sync_catalog_item_stock_mirror's exclusion of inactive variations
New migration 20260909103312_sync_catalog_item_stock_mirror.sql — trigger + one-time backfill keeping pos_catalog_items.stock_qty in sync with its active stock_count variations
Removed the item editor's item-level stock correction in both apps — it independently recomputed a delta against the now-non-authoritative mirror and silently reverted real sales on every plain item save
8 new unit tests for resolveSaleStockTargets (packages/common) + apps/terminal/e2e/stock-decrement.spec.ts driving a real cash checkout end-to-end
Accepted trade-off, not fixed here: mirror-sync trigger's row-level UPDATE on pos_catalog_items is a new serialization point across concurrent checkouts of the same item — follow-up ticket filed for an async/statement-level redesign
1 E2E tests
pos-v1.13Shipped
Void Reversal Keeps the Drawer Honest
Sep
void_pos_order/unvoid_pos_order (20260907080933) only ever touched pos_orders — voiding a cash sale never told the cash-drawer ledger, so a still-open drawer session kept expecting cash that was voided away, and the till would look 'short' at close-out by exactly the voided amount. Both RPCs now also insert/undo a compensating pos_drawer_transactions entry, reusing the existing pay_out/cash_sale types rather than a new one — the same choice edit_payment_method (20260731093943) already made for the identical problem, so the drawer history page's existing sign/color/sum logic renders it correctly with zero UI changes. Computes the order's NET cash exposure (not a blind reversal), so an order whose payment method was already corrected away from cash nets to zero and gets no extra entry. Deliberately does NOT touch an already-closed session — its expected/actual/difference numbers are a frozen historical record already reconciled against, same reasoning the Bangkok duplicate-orders cleanup (20260907072617) used; confirmed against prod that all 6 of that cleanup's voided cash orders belong to already-closed sessions and are untouched by this change. Required promoting both RPCs to SECURITY DEFINER — pos_drawer_transactions has no INSERT policy for regular authenticated users at all, matching every other writer to that table (create_pos_order_atomic, edit_payment_method); auth.uid() still resolves the real caller, so the pos_orders_prevent_completed_mutation trigger's role/voided_by authorization checks are unaffected.
New migration 20260910062920_pos_void_drawer_reversal.sql — void_pos_order/unvoid_pos_order compute net cash exposure per order and insert/undo a pay_out or cash_sale row tagged 'void-reversal:' / 'void-reversal-undo:'
Only acts while the order's drawer session is still open — an already-closed session's frozen numbers are never touched
Net-cash computation (not a blind reverse) correctly no-ops for an order already corrected away from cash via edit_payment_method
Both RPCs promoted to SECURITY DEFINER to satisfy pos_drawer_transactions' RLS (no direct-insert policy for authenticated users), with a fixed search_path
New apps/pos e2e test drives the real UI void/un-void action and asserts the drawer ledger via the admin client — reversal, undo, and the net-back-to-original-amount invariant
1 E2E tests
pos-v1.14Shipped
Void Reversal Restores Stock, Credits, and Gift Cards
Sep
void_pos_order/unvoid_pos_order (20260910062920) already reversed drawer cash on void, but stock, a customer's store-credit wallet, and a gift card's balance were completely untouched — a voided credits- or gift-card-paid order permanently kept the deduction, and voiding a sale of a tracked item never gave its stock back. This was the single most independently-confirmed critical finding in the 2026-09-09 POS audit. club_credit_transactions gains a new 'pos_refund' type (mirrors the existing booking_refund shape) to carry a reversal's link back to the order it's undoing — the existing pos_payment type's own constraint had no room for that. void_pos_order now restocks every sale via the same audited pos_apply_stock_delta path (tagged reason='void'), issues one aggregate credits refund per void event, and restores any gift-card balance plus a negative pos_gift_card_redemptions row as its own audit trail — each best-effort, with failures recorded onto pos_orders.stock_failures. unvoid_pos_order mirrors all three. A code-review pass before merge caught and fixed a real correctness bug: an order voided and un-voided more than once was double-correcting stock/credits/gift-card balances, since the original lookups matched every historical reversal row instead of only the most recent void's own rows — fixed with DISTINCT ON/LIMIT 1 scoping, independently verified via a full two-cycle void→unvoid→void→unvoid test. Also fixed in the same pass: gift-card is_active wasn't recomputed on un-void's re-debit, a failed credits re-debit was silently swallowed with only a server log, and pos_orders.stock_failures could go stale across cycles. REVOKE EXECUTE ... FROM anon added to both RPCs as defense in depth.
New migration 20260911061056_pos_void_reverses_stock_credits_giftcard.sql — club_credit_transactions gets a pos_refund type; void_pos_order/unvoid_pos_order reverse stock, credits, and gift-card balance
Credits refunded as one aggregate transaction per void event (not per payment leg) — the pos_refund type has no column to link back to a specific payment row
Code-review fix (pre-merge): a second void→unvoid cycle on the same order was double-correcting stock/credits/gift-card balances — all three reversal lookups now scoped to only the most recent void's own rows (DISTINCT ON per target / LIMIT 1)
Code-review fix (pre-merge): gift-card is_active now recomputed on un-void's re-debit, matching checkout's own is_active = balance > 0 invariant
Code-review fix (pre-merge): a failed credits re-debit on un-void is recorded onto pos_orders.stock_failures instead of only a server-side RAISE WARNING; stock_failures is now always refreshed (cleared on a clean cycle) instead of only written on failure
Shared pos_stock_adjustment_target_table() helper — the stock-table resolution logic no longer duplicated between void_pos_order and unvoid_pos_order
REVOKE EXECUTE ... FROM anon on both RPCs — the trigger's role check already rejected an anon caller; this closes the grant itself
New apps/pos e2e test drives the real UI void/un-void action across a stock + credits + gift-card order and asserts every balance via the admin client; manual SQL round-trip (including the double-cycle regression) run directly against Postgres before any application code
1 E2E tests
pos-v1.15Shipped
Refund Flow: Atomic, Role-Gated, Re-Refundable
Sep
The refund flow was three separate, non-atomic client-side writes with no role check at all (unlike void, which is owner/admin/manager-only), no server-side cap on the refund amount, and it flipped pos_payments.status to 'refunded' on ANY refund amount — hiding the option to refund the rest of a partial refund, and only ever reversing credits (cash and gift-card payments were never reversed, best-effort). New refund_pos_order_payment() RPC replaces that with one atomic, role-gated transaction, capped server-side at the remaining refundable amount, that reverses credits/gift-card/open-session drawer cash and refuses a refund on a non-completed order. pos_payments.status now distinguishes 'partially_refunded' from 'refunded' so a partial refund stays refundable. void_pos_order gets the other half of the void/refund mutual guard — refuses to void an order that already has a refund — with both functions locking the same order row so the guard is race-safe under a concurrent void + refund. refund_pos_credits/spend_pos_credits revoked from authenticated/anon entirely. A code-review pass before merge caught and fixed a real gap this same PR would otherwise have shipped with: facility_business_report (the emailed daily/weekly report RPC) and one of the three in-app report queries hadn't been updated to admit the new 'partially_refunded' status, which would have silently dropped every partially-refunded payment from both surfaces.
New migration 20260911083058_pos_refund_atomic_rpc_and_void_guard.sql — refund_pos_order_payment RPC, void_pos_order's refund guard, credits-function grant revokes
New migration 20260911100840_pos_business_report_partially_refunded.sql — widens facility_business_report's three status filters (code-review finding, caught before merge)
apps/terminal order-detail-modal.tsx — useIssueRefund calls the new RPC; role gate added; refund-button visibility now checks actual remaining balance, not just status; note field folds into the refund reason instead of being silently dropped
Void/refund mutual guard's client-side half added to all three order-detail modals (apps/terminal x2, apps/pos)
Report status filters (apps/pos + apps/terminal use-reports.ts, and the "Partially Refunded" filter chip on both Transactions pages) widened to admit the new status
packages/common/src/pos-orders.ts gains hasAnyRefund(), extracted from three copy-pasted call sites (code-review finding)
New apps/terminal/e2e/refund-reverses-effects.spec.ts — partial refund keeps the button live with the correct banner, a second refund completes it and flips status, Void hides the instant a refund exists, refund_pos_credits/spend_pos_credits confirmed unreachable
1 E2E tests
pos-v1.16Shipped
Privilege Lockdown Sweep
Sep
Phase 2 of the POS remediation plan: backend functions and RLS read policies callable with no caller authentication. Locked down 17 SECURITY DEFINER functions (customer delete/merge, package-credit assignment, the academy→POS bridge, and several newly-found ones this phase's own pre-flight audit surfaced beyond the original plan — a full customer-PII bulk read with no auth check at all, four package-session-ledger mutators with no role check, and two more facility-financial-report leaks) so each is now callable only by service_role, confirmed by grepping every real call site in both apps and finding every one already used createAdminClient(). One near-miss caught by code review: an earlier version of this migration revoked resolve_discounts from `authenticated`, which would have broken live discount-preview at checkout in both apps — packages/common/src/discounts.ts's own doc comment says it's meant to be browser-callable, confirmed by apps/pos and apps/terminal's use-discount-engine.ts hooks; fixed by keeping the authenticated grant and revoking only anon. Also tightened: pos_activity_logs' INSERT policy was named "service role inserts" but was never actually scoped to service_role (anon could insert forged audit-log rows); pos_orders_delete excluded 'completed' but not 'voided' (any staff account could permanently delete a voided order, erasing its own void audit trail via cascade); and eleven SELECT policies (the ten K2-named financial tables plus pos_activity_logs' own read side, found by re-running K2's method against the table code review flagged) were gated by nothing but facility membership — coach/member/guest could read full order/payment/drawer/gift-card/webhook-secret/Stripe-connection/staff-PII data. A systematic re-check of every _read/_write policy pair on every pos_ table found this same asymmetry on 65 tables total; only the highest-severity 4 beyond the original 10 (credentials, financial-integration config, staff PII, audit trail) were fixed in this PR — the remaining ~61, mostly lower-sensitivity catalog/menu/operational tables, are flagged for a dedicated follow-up phase rather than silently folded into what was scoped as an hours-sized item.
New migration 20260911104311_lock_down_service_role_rpcs.sql — 17 functions locked to service_role-only (or, for resolve_discounts, service_role + authenticated)
New migration 20260911104312_pos_rls_role_scoping.sql — pos_activity_logs INSERT scoped to service_role; pos_orders_delete excludes voided; 11 SELECT policies role-scoped to owner/admin/manager/staff, refactored onto the existing user_facility_role() helper instead of 10 duplicated subqueries
Code-review fix (pre-merge): resolve_discounts' authenticated grant restored after confirming a live client caller — would have broken checkout discount preview in production
Code-review fix (pre-merge): pos_webhooks (plaintext signing secret), pos_stripe_connections, pos_team_members, and pos_activity_logs' read side added — same severity class as the original 10, found by re-running this PR's own audit method more broadly
New apps/pos/e2e/privilege-lockdown.spec.ts — direct Supabase-client tests (no browser): anon rejected from every locked-down function and from pos_activity_logs INSERT; a coach-role session gets an empty read on scoped tables while a staff-role session on the same facility still sees the row; an owner-role session cannot delete a voided order
61 additional pos_ tables with the identical read/write role asymmetry found and deliberately NOT fixed in this PR — flagged as a follow-up phase, not silently dropped
3 E2E tests
pos-v1.17Shipped
Read-Policy Sweep
Sep
Phase 2b closes out the follow-up Phase 2 flagged: a systematic re-count found the real number of pos_ tables with a facility-membership-only SELECT policy (no role check, while the same table's WRITE policy correctly restricts to owner/admin/manager/staff) is 66, not the ~61 first estimated — plus 2 tables with the exact OPPOSITE defect. pos_catalogue_locations and pos_item_locations read (owner, admin, manager, coach, member) — staff EXCLUDED, coach/member INCLUDED, copy-pasted from an older CRM policy before the staff role existed. This wasn't just a privilege gap: apps/terminal and apps/pos's use-menus.ts both treat an empty read as "visible everywhere," so every staff terminal at a facility using location-scoped catalogues or items was silently ignoring that scoping in production (388 real pos_item_locations rows across 5 live locations, confirmed against prod before this phase started). Fixing the two inverted policies is therefore an intentional, confirmed behavior change on deploy (decision D12), not just a security fix. New pos_operator_facility_ids() SECURITY DEFINER helper (SETOF uuid, same shape as the existing get_my_managed_facility_ids()) replaces the 65 remaining tables' raw user_facility_roles subqueries with one hashed sub-plan per statement; pos_facility_tax_profile's two duplicate role-less SELECT policies collapsed into one, correctly role-scoped. New scripts/verify-pos-read-policies.sql commits the audit query Phase 2 and 2b both ran ad hoc as a re-runnable invariant check.
New migration 20260914045252_pos_operator_read_helper_and_inverted_policies.sql — pos_operator_facility_ids() helper; pos_catalogue_locations_select/pos_item_locations_select role array corrected in place; pos_facility_tax_profile's duplicate SELECT policies collapsed to one
New migration 20260914045557_pos_read_policy_sweep.sql — the remaining 65 tables' SELECT policies rewritten onto pos_operator_facility_ids(), each table's own join/nesting structure preserved untouched; zero client code changes
New scripts/verify-pos-read-policies.sql — committed, re-runnable invariant query (0 rows expected); run locally post-reset and intended for a pre/post check on every future prod deploy touching pos_* RLS
apps/terminal and apps/pos use-menus.ts doc comments updated to note staff/coach location-scoping reads are now correctly role-scoped (no logic change — the fix is entirely in the RLS policy)
New apps/pos/e2e/pos-read-scoping-sweep.spec.ts — direct Supabase-client tests covering one sample per policy shape (facility-keyed, location-keyed via join, order-keyed via payment->order, deepest station->location->facility nesting) plus the pos_catalogue_locations/pos_item_locations inverted-policy flip and anon still getting an empty read (not 42501) from both a pos_ table and pos_operator_facility_ids() itself
6 E2E tests
pos-v1.18Shipped
Discount Engine Tenant Guard
Sep
Phase 2c adds resolve_discounts' own in-body tenant check, the last Phase 2 standing item scoped to this function: resolve_discounts had no tenant check in its own body, only a grant-layer restriction (any authenticated caller at all) — so an authenticated caller could pass an arbitrary facility_id/profile_id pair and resolve another person's discount entitlements at a facility they don't operate. The function is reached by OpenRally over a raw DATABASE_URL connection with no JWT at all (protocol/server/src/handlers/pricing.rs, via calculate_member_price), where auth.role()/auth.uid() are both NULL — so the guard enforces ONLY when auth.role() = 'authenticated', the opposite polarity of this repo's usual "no claim -> enforce" idiom, since that idiom here would break every OpenRally member-price lookup in production. Two-level rule: a POS operator (owner/admin/manager/staff) may resolve any profile at their own facility (the till resolves for whichever customer is in the cart); anyone else may resolve only their own profile or anonymously. Pre-flight checksum comparison confirmed zero drift between local dev and production before writing the migration. NOT closed by this phase, deliberately out of scope and separately tracked (tracker's "adjacent findings" list, item 1): calculate_member_price itself is still anon/PUBLIC-executable and forwards an arbitrary caller-supplied p_user_id straight into resolve_discounts — since auth.role() for an anon-key caller is 'anon', not 'authenticated', this guard does not fire on that path, so an unauthenticated caller can still enumerate another profile's discount entitlements via calculate_member_price today. Needs its own caller/grant audit before it can be closed (candidate: Phase 2d).
New migration 20260914070621_resolve_discounts_tenant_guard.sql — full CREATE OR REPLACE inserting a 9-line guard after the existing facility-id null check; grants re-asserted unchanged (authenticated + service_role, anon still revoked)
packages/common/src/discounts.ts doc comment updated to note the new tenant-guard contract (no logic change — callResolveDiscounts itself is untouched)
apps/pos/e2e/privilege-lockdown.spec.ts extended with 6 new cases: staff resolving a different profile at a facility they don't operate (42501) vs at their own facility (succeeds); a non-operator resolving their own profile (succeeds) vs someone else's (42501); anonymous resolution (NULL profile, succeeds with empty lines); anon still rejected outright (regression)
Verified every real caller: apps/pos + apps/terminal checkout discount-preview hooks (operator at own facility, unaffected), apps/admin's booking + discount-rules-preview routes (service_role via createAdminClient, guard skipped entirely), OpenRally emulation via a direct psql calculate_member_price(...) call (NULL claim, guard skipped, still succeeds), supabase/snippets/discount_engine_evals.sql (runs as postgres, unaffected)
Code-review finding (pre-merge, correctly deferred not fixed): calculate_member_price is still anon/PUBLIC-executable and forwards a caller-supplied p_user_id into resolve_discounts — an anon-key caller's auth.role() is 'anon', not 'authenticated', so this phase's guard doesn't cover that path. A tripwire test documents the current, still-open behavior (expected to start failing once a future Phase 2d closes calculate_member_price's own grant) rather than silently omitting coverage
7 E2E tests
pos-v1.19Shipped
Settlement Tenders — Stop Writing Zeros
Sep
Phase 1 of "settlement tenders": prepaid court bookings and package-funded lesson sessions used to be recorded by the POS at ฿0 with nothing explaining why — an auditor couldn't tell "paid earlier" from "given away" from "forgot to charge," daily sales were understated on every day with prepaid activity, and the two checkout paths (till vs. QR payment-link) disagreed on whether the line even appeared. Every such line now carries its real value, and an automatic "settlement" payment row is written alongside it — method prepaid or package, pointing at the original booking payment or package session-ledger row — that brings the amount actually owed at the till to zero. Real value in, automatic tender out: gross sales, item/category reports, and units sold all become correct as a side effect, matching how credits and gift-card tenders already worked. Session value for a package redemption is frozen at purchase (price ÷ total sessions across the package's own class types, no per-class-type price signal exists anywhere in the schema so a uniform split is the only defensible default), via a new player_packages.session_value_cents column set by a BEFORE INSERT trigger. Spans all three apps that touch check-in — apps/terminal, apps/pos, and apps/admin — plus both server-side order-creation paths that bypass the till entirely (the QR payment-link cron and the Stripe webhook).
New migration 20260914092002_pos_settlement_tenders.sql — pos_payments.settlement_ref (jsonb), pos_order_items.settled_by, prepaid/package added to the payment-method registry and CHECK constraint, create_pos_order_atomic updated to validate + insert settlement rows
New migration 20260914094120_player_packages_session_value.sql — player_packages.session_value_cents frozen via BEFORE INSERT trigger (price_amount ÷ total sessions across the package's own package_items rows)
New migration 20260915050142_pos_settled_lines_must_have_value.sql — narrower, correctly-scoped P1-7: a line this phase marks settled (settled_by set) must carry a real value (total_cents > 0), NOT a blanket ban on every ฿0 line — five other legitimate, pre-existing zero-price paths (unpaid split-bill placeholders, zero-priced catalogue items, a negative-modifier clamp, a split-share floor, an omitted total defaulting to zero) would have broken under a blanket rule
resolveCourtSettlement / resolveLessonSettlement (packages/common/src/check-in.ts) — settlement resolvers for court bookings (incl. per-player organizer legs) and lesson students, shared by all three check-in-calling apps; a genuinely complimentary booking gets is_foc/foc_reason instead of a settlement, since nothing was ever collected for it
Checkout in both apps + the QR cron + the Stripe webhook: order subtotal/total include settled lines at real value (matching how credits/gift-card already work); the amount actually charged via cash/card/etc. subtracts both settled and comp'd (is_foc) line values
apps/pos parity fixes: 4 places (subtotal, two discountable-subtotal computations, split-bill) that never got the settled-line exclusion apps/terminal already had, plus a split-eligibility gate so staff can no longer Split (only Move) a settled booking line and divide its frozen value
Code review (pre-merge, all fixed not deferred except where noted): checkInPlayer's organizer leg wasn't wired into the settlement resolver at all; a comp'd line's value wasn't excluded from the amount charged at checkout; both apps' cash-drawer entry fell back to the order's gross total instead of the amount actually owed; the QR cron and Stripe webhook built line items from the full cart but indexed per-item discounts against a filtered one, misaligning discounts onto the wrong item whenever a settled/comp line was present; a lesson package settlement that failed to resolve (data-integrity gap) kept showing a misleading "Paid" badge while still billing the student. Deferred: two lower-severity code-duplication findings in apps/pos (a hand-copied settlement-payload builder; an inlined payableItems predicate) — real but not money-correctness, tracked as follow-up rather than touched pre-merge
e2e specs updated for real settled-line pricing (tc-113, tc-114, tc-270) and one clarifying comment (tc-274, no functional change); tc-278 and tc-269/272/106 confirmed unaffected by reading, not assumed
4 E2E tests
pos-v1.20Shipped
Duplicate Item Name Warning (POS-05a)
Oct
Pablo's Bali ops punch list: two items could share a name with no warning, and reports silently split sales across both. On save (create or rename) in both apps/pos and apps/terminal's item modal, an exact, case-insensitive, facility-scoped name match now warns before saving and offers 'Keep both' (proceeds as normal). The actual merge is a separate, larger ticket (POS-05b — needs its own migration to re-point ~14 FKs on pos_catalog_items + ~9 on pos_item_variations around pos_order_items' immutability triggers) and is NOT built here; 'Merge into existing item' is shown disabled rather than hidden, so the capability is visible and expected. No DB constraint added — soft warning only, so existing duplicate data isn't retroactively blocked. See DEC-493, DEC-494.
useCheckDuplicateItemName() (apps/pos + apps/terminal's queries/use-catalog-items.ts) — exact ILIKE match (% and _ escaped) against pos_catalog_items, facility-scoped, excludes soft-deleted items and the item's own id in edit mode
item-modal.tsx (both apps): save handler runs the check before the actual create/update mutation; a hit parks the validated form values and shows a warning dialog instead of saving immediately
Warning dialog: 'Keep both' re-invokes the save with the duplicate check skipped for that one call; 'Merge into existing item' shown disabled with a 'Coming soon (POS-05b)' tooltip; 'Cancel' discards the pending save and leaves the modal open
Covers apps/terminal's till 'create item from checkout' quick-create (menu-grid.tsx's createItemOpen) for free — it renders the same ItemModal. apps/pos's equivalent quick-add dialogs never write pos_catalog_items (addCustomAmount ad-hoc cart lines), so correctly out of scope
packages/common/src/category-matching.ts — findSimilarCategories (already generic over any {id,name} list) exported under an additional findSimilarItems alias for a possible future fuzzy 'did you mean' pass; not wired into this warning, which uses exact match only per the ticket's stated minimum
New apps/pos/e2e/duplicate-item-name-warning.spec.ts — TC-01 create-duplicate + Keep both persists both rows, TC-02 rename-to-duplicate warns and Cancel leaves the rename unsaved, TC-03 a plain re-save under the item's own unchanged name never warns
3 E2E tests
pos-v1.21Shipped
One shared report date range across the whole POS admin session (REP-04)
7 Oct
Pablo's Bali ops punch list: every page of the POS admin reset the date range — picking "Last 7 days" on Sales and then opening Discounts (or Payments, or Transactions) meant setting it again, every time. useReportDateRange now restores and persists the range through a new sessionStorage-backed store, so it stays set across every report page — Sales, Discounts, Payments, Transactions, every other reports/* page, and the dashboard — until changed or logged out (DEC-496). Stored as facility-local calendar dates, not UTC instants, so a restored custom range still resolves correctly after a location/facility switch changes the active timezone.
New apps/pos/src/stores/report-range-store.ts — zustand + persist, sessionStorage (not localStorage: a per-session default, not a permanent cross-visit one), key `setpoint-report-range`, same shape/convention as the existing stores/location-store.ts
useReportDateRange (components/ui/date-range-picker.tsx): initial value is the URL-carried range (REP-05, highest precedence), else the persisted session range, else the hook's own defaultPreset. The persisted range is applied from a post-mount effect, never read during the initial render/useState initializer, so the client's first render never disagrees with the always-unpersisted server-rendered default (sessionStorage rehydration can finish before React's hydration pass even starts). setRange now writes through to the store on every call — covers all 16 reports/* pages (13 direct + 3 via hooks/use-report-url-state.ts) plus the dashboard, with zero per-page changes
Sign-out (providers/auth-provider.tsx) clears the persisted range — both the explicit signOut() path and the SIGNED_OUT auth-state-change event, with the clear fired immediately before the navigate (no intervening await) so a concurrent in-flight interval tick can't write a fresh range back into the store during the sign-out's own async gap — so "until logged out" holds even across a sign-out/sign-in in the same tab without a reload
New e2e spec: set a range on Sales Summary → Discounts/Payments/Transactions show the same range instead of their own default → reload persists it → log out clears it
1 E2E tests
pos-v1.22Shipped
SEC-01 — Line Add/Remove Attribution
Oct
Pablo's ops punch list (Bali) asked that every bill action be attributed to the specific PIN-verified staff member, not the shared till login — create, discount, and close already were; this closes the line add/remove gap. apps/terminal/src/mutations/use-ticket-mutations.ts's useSaveTicketCart now reads a ticket's cart_json before the write and diffs it against the incoming cart by CartLineItem.id (cart_json has no field literally named line_ref — that name is a different concept in packages/common/src/discounts.ts — so the id is reused as the diff identity and surfaced as line_ref only in the emitted payload), emitting ticket_line_added / ticket_line_removed / ticket_line_qty_changed events attributed to useTerminalLock()'s live PIN identity. pos_activity_logs.acting_user_id/approved_by_user_id (added by migration 20260929073234, unused until now) finally get written: LogActivityInput gained both fields generically, not single-purpose, since the separate void/refund attribution ticket is already known to need the same extension. No database migration. The pre-existing generic ticket_cart_updated event is kept alongside the new granular ones, not retired — it's still the only event covering a line-less save (a modifier/note/discount-only edit). A second /code-review pass surfaced and fixed a real, pre-existing bug this diff was the first thing to actually depend on: terminal-store.ts's loadCartFromTicket discarded a restored line's real id on every ticket reopen, which would have made every untouched line look removed-and-re-added on the very next save of a reopened ticket — see DEC-499.
apps/terminal/src/app/actions/log-activity.ts — LogActivityInput gains optional actingUserId/approvedByUserId, written to pos_activity_logs.acting_user_id/approved_by_user_id when supplied; both default null, every existing caller unaffected
apps/terminal/src/mutations/use-ticket-mutations.ts — useSaveTicketCart pre-reads cart_json, diffs by CartLineItem.id via a new diffCartLines() helper, emits ticket_line_added/ticket_line_removed/ticket_line_qty_changed with acting_user_id from the current PIN identity; generic ticket_cart_updated kept unchanged alongside it
apps/terminal/src/app/(dashboard)/[facility]/activity-logs/page.tsx — ACTION_CONFIG entries + icons for the three new action types
apps/terminal/src/stores/terminal-store.ts — loadCartFromTicket now preserves a restored line's persisted id instead of always minting a fresh one (DEC-499); the stable identity diffCartLines depends on
New apps/terminal/e2e/line-edit-attribution.spec.ts — UI-first: logs in as the shared till login, re-locks and unlocks with a SEPARATE staff member's own PIN (so identity.userId and the auth session's own user.id are genuinely two different people), adds a cart line, saves, reopens the ticket, removes the line, saves again; asserts pos_activity_logs.acting_user_id matches the PIN-verified operator and NOT the shared till login on both the add and the remove event, and that the line_ref is the same cart line identity on the way out as on the way in
1 E2E tests
pos-v1.23Shipped
Inline + bulk Group/Category editing in the items list (POS-04)
7 Oct
Pablo's Bali ops punch list: changing an item's group or category meant opening the full edit form. Both the Group and Category cells in the items-list row are now click-to-edit selects (apps/pos and apps/terminal), and a bulk "Group" toolbar action sits next to the existing bulk Category one — reusing the already-shipped useBulkMoveToCatalogue/useMoveItemToCatalogue/useBulkSetCategory mutations rather than adding new ones. Category options are always scoped to the row's current group, same rule the item detail modal already enforces; switching group on a single row clears/rescopes the category in apps/pos (category is nullable there) and forces an immediate re-pick in apps/terminal (category is required at the mutation layer, POS-01/02/03) — see DEC-500 for the full reasoning.
Items-list toolbar: new bulk "Group" dropdown (both apps), offering only groups compatible with EVERY selected item's item_type (isCompatibleCatalogue, same rule the per-row kebab action already uses). Both apps group the selection by its current source catalogue and call useBulkMoveToCatalogue once per source group (fromCatalogueId covers one source per call), wrapped in a try/catch so a mid-loop failure leaves the selection in place for a retry instead of throwing an unhandled rejection past clearSelection().
Inline row editing: Group and Category table cells are click-to-edit Radix Selects (defaultOpen, stopPropagation so the click never also triggers the row's own openEdit). Group reuses useMoveItemToCatalogue; Category reuses useBulkSetCategory with a single-item id array. A new categoryOptionsForCatalogue helper (catalogue_id → valid category ids) mirrors item-modal.tsx's groupCategories — explicit pos_catalogue_categories membership, plus a fallback for categories that already have items in the group.
apps/terminal: added useCategoryCatalogueMap to use-catalogue-categories.ts (apps/pos already had it) — a facility-wide category→catalogue-ids lookup, needed for the inline editor's scoping instead of apps/pos's existing per-catalogue-only hook.
🔴 Code-review fix, pre-merge: apps/terminal's useMoveItemToCatalogue/useBulkMoveToCatalogue previously only patched catalogue_id and never synced pos_item_catalogues — harmless while unused, but checkout reads catalogue-scoped tax/service-charge eligibility from that exact junction table, and this PR is what first wires these mutations into a live UI path. Fixed to sync the junction table (mirroring apps/pos), including the pre-existing per-row "Move to catalogue" kebab action's call site, not just this PR's new ones (DEC-500).
Parity cleanup: removed apps/pos's bulk Category "— No category" option, matching apps/terminal's existing required-category behavior (fix/pos-terminal-item-category-required) — Enable/Disable already covers temporarily unlisting an item without touching category assignment. Updated the two pre-existing e2e assertions in v0.6-bulk-operations.spec.ts that targeted the removed option.
Code-review fixes, pre-merge: the Group-switch's async onSuccess (which reopens the Category cell) now checks editingCell hasn't moved to a different row before reopening, so it can't steal focus back and clobber a concurrent edit on another row; useLinkCategoryToCatalogue/useUnlinkCategoryFromCatalogue now also invalidate the category-catalogue-map query key the new inline editor reads; a toast now warns if the forced category re-pick after a group switch is dismissed without picking (apps/pos: item left uncategorized; apps/terminal: stale category paired with the new group).
New e2e spec in both apps (items-inline-group-category.spec.ts): bulk group move across a mixed-source selection, inline category edit scoped to the row's group, inline group edit including the clear/rescope (apps/pos) vs forced-re-pick (apps/terminal) divergence.
6 E2E tests
OpenRally Protocol
3 versions shipped
Open booking protocol for sports facilities — vendor-neutral standard for availability, holds, and bookings.
v0.1.0
Protocol Server
v0.2.0
Payment Block (Protocol v1.1)
v0.2.1
Lesson-Hold TTL Covers Payment Window (BUG-098)
v0.2.2
Coach Availability Agrees With The Hold (BUG-105)
v0.1.0Shipped
Protocol Server
6 Mar
Rust/Axum open booking protocol server — 16 REST endpoints + SSE, adapter over Setpoint DB, deployed on Fly.io Tokyo
Discovery — facilities, resources, sports with cursor pagination
Availability — 30-day slot materialization from court schedules
Deployed on Fly.io Tokyo (nrt) — same region as Supabase
v0.16 alignment verified — match bookings, multi-facility isolation, schema compatible
90 E2E tests
v0.2.0Shipped
Payment Block (Protocol v1.1)
21 Apr
Optional vendor-neutral `payment` block on bookings. Follows OpenTravel Alliance + Stripe Connect — generic status + method_type enums plus opaque consumer-supplied external reference. OpenRally stays payment-agnostic; the block is metadata for PMS-side reconciliation, not a processing interface. Fully additive / backward compatible.
Public docs: /objects/payment page on openrally.vercel.app
9 E2E tests
v0.2.1Shipped
Lesson-Hold TTL Covers Payment Window (BUG-098)
28 May
OpenRally lesson-hold TTL now covers the consumer's payment window. The lesson hold previously defaulted to 5 min — shorter than a typical consumer payment flow — so a held court could be released (and snatched) mid-payment. The hold endpoint now honors an optional `hold_duration_minutes` request field and the default was raised 5→15 min so even a consumer that omits the field gets a window long enough for a normal payment. Stays consumer-agnostic — exposes a duration knob, doesn't bake in any consumer's number (same shape as the Slot Duration Decision). padel-backend sends 15 (= its 10-min payment window + a 5-min buffer). Fixes BUG-098. Verified live 2026-05-28 (coaching booking #60839, Sandbox Pad Thai Padel). See DEC-244.
Lesson-hold endpoint honors optional `hold_duration_minutes` request field; falls back to the server default when absent
Default lesson-hold TTL raised 5 → 15 min so an omitted field still covers a normal payment window (no-silent-expiry backstop)
Consumer-agnostic — protocol exposes a duration knob, does not encode any consumer's payment-window number (matches the Slot Duration Decision)
Verified end-to-end 2026-05-28 — slow payment through the full window still completed, court not taken mid-payment (booking #60839)
v0.2.2Shipped
Coach Availability Agrees With The Hold (BUG-105)
10 Aug
Coach lesson-slot availability now accounts for the coach, not just the court. The endpoint derived each slot's status from the `bookings` table alone, matched on the slot's OWN court — but a coach reserved on N courts emits N parallel slots per instant, and a lesson's booking row lands on one court only. The other N-1 kept reading 'available' while the coach was mid-lesson. `hold_lesson_slot` has always disagreed: it rejects any overlapping non-cancelled lesson for the coach BEFORE it looks at courts, so consumers surfaced the phantom slot and the conflict only appeared as a 409 slot_unavailable at book time. Status is now the strongest of two independent passes (available < held < booked): court occupancy as before, plus coach occupancy — non-cancelled lessons of that coach at that instant, on any court and any facility. The coach predicate is deliberately identical to the RPC's guard, so a slot this endpoint calls 'available' can no longer be one the RPC rejects. Observed at Pad Thai Padel 2026-08-12: a coach taught 15:30–17:00 on one court while 15:00 and 16:00 stayed bookable on another. Restores parity with the admin twin (lessons/available), which already checked coachBusy. Fixes BUG-105. See DEC-289.
Coach-occupancy pass added to the lesson-availability handler — non-cancelled lessons block every court the coach is reserved on, not just the lesson's own
Predicate mirrors hold_lesson_slot exactly (coach-scoped, not court- or facility-scoped) so availability can never advertise a slot the hold RPC rejects — see DEC-289
Slot status is now the max of the court and coach passes (available < held < booked); an in-flight lesson hold reads 'held' on parallel courts, matching what the court pass reports on its own
Court pass moved from assignment to escalation — deterministic when a slot overlaps both a hold and a confirmed booking
3 integration tests — cross-court regression (fails pre-fix), cancelled lesson does not block, in-flight hold reads 'held' on parallel courts (mutation-verified)
PENDING DEPLOY — flyctl deploy --remote-only from protocol/server; Rust only, no migration and no edge function
Setpoint CRM
No versions yet
Sports-native customer relationships, loyalty, and engagement — built on the same DB as POS and PMS.
CRM MVP in planning — versions below are the full roadmap.
Features
PlayByPoint Parity Matrix
Feature-by-feature comparison with the market leader.
Setpoint PMS
44 features compared
Court booking & club management platform for padel, tennis, and pickleball facilities.
Feature
Setpoint
PlayByPoint
Notes
International address + contacts
v0.19
District, neighborhood, lat/lng, facility contacts CRUD — built for Asia
Search, edit, cancel with refund, conflict detection, type filter (court/coaching/event)
Facility settings
v0.5
Info, branding, logo/cover upload
Auto-pricing engine
v0.6
8-level cascade, auto-calculate on booking. v0.35 adds a per-rule 'fixed' mode for non-linear per-duration pricing (e.g. 90 min isn't always 1.5x the 60-min rate).
User management
v0.7
Customer directory, team RBAC, role assignment
Pricing rules UI
v0.7
Pricing rules CRUD, price preview calculator. v0.35 adds an Auto-calculate / Fixed price per duration toggle.
Dashboard & analytics
v0.7
Summary cards, charts, court utilization
Memberships + subscriptions
v0.8
3 tiers, assign/cancel, member pricing
Programs (clinics, leagues)
v0.8
CRUD, enrollment, waitlist, auto-promote
Credit-accumulation memberships
v0.8.1
Two models (subscription vs credit-accumulation), wallet top-ups, rolling tiers
Centralized Loyalty & Discount Engine
v0.24
Single resolver for every customer-facing discount: predefined CRM groups (e.g. VIP, Staff, Wholesale) + auto-mirrored membership-plan / credit-tier rules. v0.24 ships engine + admin only — admin booking flow consumes the resolver and serves as the canonical pattern; POS + Terminal consumer wiring is delegated to the POS/Terminal dev as a follow-up (BUG-082) so ownership stays with the author. Polymorphic actor (group / plan / tier), permissive stackability (per-rule flag, default false, 100% cap), idempotent ledger (crm_discount_applications) for reporting/audit.
Public REST API
v0.9
60 endpoints, CORS, API key auth — any frontend can integrate
Decoupled public booking app
v0.10
Public app consumes REST API — zero direct DB access
Academy entity, coach profiles, classes, lessons, packages, commissions. v0.29 added bounded weekly/monthly recurrences + day-of-week filter on date_range. v0.30 adds per-court availability_exceptions, gate-honors-exceptions (BUG-093), inline 'Ignore this conflict' button. v0.35 adds a Students tab (customer↔student relation via a read-only academy_students view, DEC-274) with private/group completed-lesson history, also surfaced on the Customer 360 Coaching tab. v0.36 splits Students into All Students / Students with Packages (recency + coach filters, search, pagination). v0.37 adds a Groups sub-tab (ALB-38, DEC-277/278) — level-based training cohorts, manually assembled, many-to-many membership, per-player package + session count on the roster.
Lesson packages (session bank + recurring + lifecycle)
v0.20
Session bank + recurring, session consumption on booking, auto-expire/exhaust, refund on cancel, admin management, portal CTA
Public lesson booking
v0.13
Coach-first + time-first discovery, hold-confirm flow, Stripe Elements
Group lesson join
v0.13
Browse open group lessons, join with payment, capacity tracking
Lesson package purchase (public)
v0.13
Session bank + recurring subscription via Stripe
OpenRally Protocol (open booking standard)
protocol
Rust/Axum server — 17 endpoints, SSE events, rate limiting, 91 tests, adapter over Setpoint DB
Multi-leveling (multiple skill-level systems per player)
v0.32
Club enables which systems show per sport (Setpoint Elo + external Connected-app / WPR / DUPR / NTRP). External levels pulled READ-ONLY via the new OpenRally player-level ingest endpoint (email-match, consumer-agnostic). DEC-258.
Facility selector, sidebar switcher, cross-facility dashboard, isolated data per facility
Embeddable widget
v1.0
Script tag for any club website — v1.0
Kiosk mode
v1.0
Front-desk tablet check-in — v1.0
Bottom line: PlayByPoint quality, without the login wall. The frictionless booking flow (no account required) is our core differentiator — targeting >60% booking completion rate vs. industry 30–40% with login walls.
530+ tests across 88 files (268 admin in 37 files + 138 web in 19 files + 5 demos in 5 files + 51 production verification in 10 files + 73 demo-populate in 11 files). Admin tests: UI + admin API + flow tests + package lifecycle (15 tests) + Centralized Discount Engine end-to-end (TC-130, v0.24.0) + bounded coach availability (TC-153–157, v0.29.0) + per-court exceptions / gate-honors-exceptions / Ignore-this-conflict button (TC-161–163, v0.30.0) + Academy Students tab / customer↔student relation (TC-255–259, v0.35.0) + Academy Groups (TC-262, v0.37.0). Web tests: real UI E2E + flow tests + Mailosaur email tests + 12 Stripe payment tests + package lifecycle (10 tests). Production verification: 51 tests against deployed apps. Demo-populate: 73 tests that populate local or production with rich demo data through real user flows. No seed.sql — all data created via browser automation.
Demo Videos
Feature Demos
Recorded walkthroughs of key features — captured from E2E test runs.
Setpoint PMS
0 demo videos
Court booking & club management platform for padel, tennis, and pickleball facilities.
Setpoint POS
coming soon
Point-of-sale for front desk operations — walk-in bookings, payments, and retail.
No demo videos yet.
OpenRally Protocol
coming soon
Open booking protocol for sports facilities — vendor-neutral standard for availability, holds, and bookings.
No demo videos yet.
Setpoint CRM
coming soon
Sports-native customer relationships, loyalty, and engagement — built on the same DB as POS and PMS.
No demo videos yet.
Demos
Live Demo Access
All demo links point to the Sunset Padel Club test facility.
Setpoint PMS
9 demo links
Court booking & club management platform for padel, tennis, and pickleball facilities.