Backtracking Log
Timestamped record of every non-trivial decision for backtracking and context.
Timestamped record of every non-trivial decision for backtracking and context.
535 decisions recorded
Ported the fix, following the identical pattern PR #1256 proved out (fixed isNewAccount tracking via a new findOrCreateGuestAccountWithStatus, RACE_WINDOW_MS + anon-placeholder-downgrade guards, deleteOrphanedGuestAccount called on the race loser) — the different facility-linking model turned out NOT to change whether the orphan-cleanup fix is needed (a customer created via apps/pos's till-style order-screen "New Customer" flow, apps/pos/src/components/terminal/customer-selector.tsx, that loses the phone race is just as untied/unreferenced in apps/pos's model as in apps/terminal's — zero bookings/orders/tickets/memberships, so isTiedToAnyFacility correctly flags it as an orphan regardless of which facility-linking mechanism a given app uses), only how to verify it. Explicitly did NOT port linkCustomerToFacilityDirectory/crm_customer_directory tie-in to apps/pos — that's a separate, deliberate architectural difference (apps/pos customers become visible once they have real activity, not via an explicit directory row) outside this bug's scope; porting it would do nothing anyway since search_facility_customers never reads crm_customer_directory. Duplicated rather than shared via packages/common: apps/pos never imports @setpoint/database/admin (uses raw `createClient(url, serviceRoleKey)` throughout its own action files instead), and find-or-create-guest-by-phone.ts's own existing header comment already documents this repo's established convention for these specific action files as "POS-side mirror ... kept in one place to avoid drift between the two near-identical action files this repo's per-app server-action convention already produces" — so a new apps/pos-local delete-orphaned-guest-account.ts + customer-facility-access.ts (isTiedToAnyFacility only; apps/pos has no edit-guard caller for the single-facility isTiedToFacility terminal also exports) matches the pattern already in place rather than introducing a new shared-module precedent unilaterally. Verified live, not assumed: reproduced the orphan-leaving failure against local Supabase before porting the fix (two concurrent same-phone "New Customer" submissions through the real UI left 2 customer rows for 1 phone number on the unfixed code), then confirmed exactly 1 row survives + 3 new Playwright e2e tests pass (phone-only race, email+phone race, permanent-conflict non-race) across two separate runs post-fix. Also re-verified apps/terminal's own #1256 fix still passes live (its existing customer-create-phone-race.spec.ts, 3/3, run fresh against current main) rather than trusting the merged PR's report.
Alternatives considered
DEC-545 was right to defer apps/pos rather than copy-paste blind — the facility-linking model genuinely is different, and blindly porting linkCustomerToFacilityDirectory would have been a no-op dead write. But the orphan-cleanup fix itself doesn't depend on which facility-linking mechanism an app uses, only on "does this customer have ANY real-world tie," which isTiedToAnyFacility already checks generically. Same discipline as #1256's own review: proved the bug live before fixing it, kept the same race-vs-permanent-conflict and real-email-vs-anon-placeholder safety distinctions that caught two real data-loss bugs in #1256's review, and re-verified the terminal side live rather than trusting a prior report. Code review on THIS port caught one more gap, symmetric on both sides: `customers` is a single table shared by apps/pos and apps/terminal, but each app's winnerIsAnonPlaceholder regex only recognized its OWN anon-fallback domain (@pos.local / @terminal.local) — a race winner created by the OTHER app's anon-fallback wasn't recognized as a placeholder, so a real email typed on one app could still be silently downgraded by a placeholder the other app created. Fixed in both apps' use-customer-mutations.ts (regex now matches either domain) in the same commit, since this is a pre-existing gap in the already-merged #1256 terminal code too, not something new introduced by porting the fix to apps/pos.
Ship the interim mitigation now: a new deleteOrphanedGuestAccount server action (apps/terminal/src/app/actions/delete-orphaned-guest-account.ts), called only when the race-recovery branch confirms a winner exists for the phone. Before deleting, it independently re-verifies the target server-side rather than trusting the call site's comment: the email matches what THIS call just set on the row (a lightweight ownership proof — not a cryptographic token, but a fact an unrelated id/email pair won't coincidentally share), no phone landed on it, it has zero ties to any facility (isTiedToAnyFacility, sharing customer-facility-access.ts's six-plus-player_packages-table check with the three existing update actions' facility-scoped edit guard, rather than a second, independently-fallible copy), and it was created within the last 5 minutes (with a clock-skew tolerance, since Vercel and Supabase are different machines). Any error anywhere in that chain refuses the delete (fails CLOSED) rather than risking an irreversible cascade-delete on unverified data. This is explicitly an INTERIM MITIGATION, not the architectural fix — it narrows the exploit/accident surface a lot (from "any real customer, any time" to "an untied, phoneless, <5-minutes-old record whose exact email you also know") but cannot fully close it without either a request-scoped ownership token or the atomic claim-or-create redesign described above. ARCHITECTURAL FOLLOW-UP (not yet filed as a ticket — flagging here so it isn't silently forgotten): redesign guest-account creation so the phone claim happens atomically via the DB's own `customers_phone_key` unique constraint (e.g. create the auth user lazily only after a server-side upsert/RPC confirms this phone isn't already claimed, or hold a short-lived claim row keyed by phone before ever calling auth.admin.createUser), eliminating the speculative-create-then-maybe-delete shape entirely rather than cleaning up after it.
Alternatives considered
An irreversible delete needs a higher evidence bar than a reversible edit guard, so this layers multiple independent, cheap-to-verify signals (ownership-by-email, no-phone, zero-ties, fresh-only, fail-closed-on-error) rather than trusting any single one — found via three independent code-review passes repeatedly re-discovering the same residual gap (an exported server action with no deterministic ownership check), which is exactly why it's logged here instead of being silently accepted. Not implemented, and explicitly deferred: porting this same fix to apps/pos (which has the byte-for-byte identical phone-race bug, confirmed by code review, but a different/absent facility-linking model that needs its own look before copy-pasting a delete-capable action into it) and the atomic claim-or-create redesign itself.
Template: the four existing right Sheet panels (academy-member-detail-panel, academy-collections-detail-panel, customer-detail-panel, academy-payments-reminder-panel) — full-height Sheet from the right, `w-full` on phones, `sm:max-w-xl` on desktop, no <Table> inside — and booking details is converted to that pattern first, so the surface Nestor named becomes the thing it describes. Rule, applied to all 56 component files: a RIGHT PANEL when the operator works inside it — any detail/record view, and any form that already needed its own scrollbar (max-h + overflow-y-auto) or sticky footer, or that has sections/steps/tabs (create/edit booking, schedule lesson, class, coach, package, academy settings, assign package, pricing rule, discount rule, court images, the two report edit forms, program form, match form, match detail). A CENTRED DIALOG when the operator answers it — confirm and destructive prompts (archive/delete/cancel/remove), pickers (add to shift, assign coach, add from segment, cancellation reason), result/alert displays (API key created, payment link, chase results, send result), and one-block quick-entry forms that fit on screen without scrolling (customer, team member, court, membership plan, assign subscription, add credits, enroll user, create group, new API key, record payment, adjust/extend sessions, record a note, add tier). Also a DIALOG regardless of size when the content is intrinsically wider than the sm:max-w-xl panel and would need a sideways scroll or a table inside a panel, both banned: coach-schedule-editor (max-w-3xl weekly grid), message-templates-manager (max-w-3xl editor + preview), lesson-roster-panel (max-w-4xl/5xl roster table), the two email-preview dialogs, court-schedule-editor's week rows. Outcome: 17 panels / 39 dialogs. Ten panels converted and browser-verified tonight; the other seven stay Dialogs and are listed in v0.53.20 for a follow-up — three of them (program/match form, match detail) are PARKED features and are not modified by rule anyway. Primitive untouched: sheet.tsx stays as it is, converted panels take `flex flex-col gap-4` on SheetContent to reproduce DialogContent's vertical rhythm. Files keep their *-dialog.tsx names and *Dialog exports.
Alternatives considered
The rule is written so classification is predictable from two observable signals (did it already need to scroll or have sections; is it a decision or a view) plus one hard constraint (width). Industry check by name: Shopify Polaris uses a Modal for confirmations and short forms and a Sheet for "supplementary content and actions that are not the main focus"; Linear opens issue detail and long forms as a side peek and keeps destructive confirms centred; Stripe Dashboard's record detail and multi-step forms are right drawers while delete/confirm remain centred. The split landed here matches all three. Keeping the primitive untouched keeps the blast radius to the ten files whose behaviour was driven in a real browser tonight.
(1) pos_catalog_items.price_mode text NOT NULL DEFAULT 'fixed' CHECK IN ('fixed','open') — not a boolean — to match this table's existing mode-column convention (tracking_type, availability_status are both text+CHECK) so a third mode never needs a second migration to widen a boolean into an enum. pos_item_variations.price_cents is untouched (stays NOT NULL); for an open item it's simply read as an optional suggested starting value, never enforced. (2) Open price is staff-allowed, NOT manager-gated — Pablo's phrasing ("ask for a price") describes a normal sale mechanic, not a discount; gating it behind a PIN would make an everyday sale (e.g. a market-rate racquet restring) slower than a scripted item for no safety reason POS-08 doesn't already cover. (3) POS-08 took the activity-log-only approach: setLinePriceOverride (terminal-store.ts) mutates the cart line's unitPriceCents directly (same mechanism a discount does NOT use — a discount is a separate deduction; an override replaces the price), and use-order-mutations.ts logs one pos_activity_logs row per overridden line (action: price_override_applied, entity_type: 'order', entity_id: the real order id) right after checkout commits — never touching pos_order_items or the checkout RPC. order-detail-modal matches a line back to its log entry by (item_variation_id, unit_price_cents) since there's no FK, which is an accepted, documented ambiguity only two co-priced identical-variation lines on the same order could ever hit. (4) Scoped to apps/terminal only for both the at-sale open-price prompt and the price-override control: apps/pos's own /terminal checkout route is a live but nav-unlinked dead surface (flagged separately, DEC-501) with no manual-discount/PIN-approval gate to reuse at all — POS-08 explicitly says reuse the EXISTING gate, and apps/pos has none. The price_mode TOGGLE itself ships in BOTH apps' item-modal.tsx (catalog management happens from either app), but only apps/terminal's menu-grid actually prompts for a price at sale.
Alternatives considered
Reuse before new state (CLAUDE.md's standing bias): price_mode extends the exact enum-column pattern this table already has twice; the override reuses the exact PIN gate (DiscountApprovalGate/canApplyManualDiscount) and the exact actingUserId/approvedByUserId LogActivityInput extension a prior PR added specifically anticipating this feature (see log-activity.ts's own comment, written before this ticket existed). Industry comparison, checked by name: Square POS supports a "Variable Price" item type exactly like POS-07 (prompts for a price at sale, no manager gate) and a separate, PIN/passcode-gated "Price Override" permission on an existing line — the same staff-allowed/manager-gated split this decision lands on, not a self-invented rule.
Same bug, confirmed independently by reading the checkout RPCs' own INSERT statements (not trusting DEC-497's finding secondhand): checkout-dialog.tsx's pendingDiscount carries {type, input} right up to use-order-mutations.ts, which only ever sends discountCents and hardcodes type: 'fixed' on the RPC payload — the percent is dropped on the client side, before it ever reaches the database. Fix is at INSERT time inside create_pos_order_atomic (the immutability triggers confirm UPDATE is a dead end), via one new nullable pos_order_discounts.percent column, written only when the client sends type='percentage' with a sane [0,100] value — covering till-checkout (apps/terminal AND apps/pos, which keep independently-drifted copies of use-order-mutations.ts/checkout-dialog.tsx) and the payment-link cron path (check-payment-links/route.ts), all three of which had the identical hardcode. Bundled into the SAME migration: get_bill_payload_for_order (already the one function feeding both the printed receipt via enqueue_bill_print_job_for_order AND the emailed bill via send-bill-email) gains `discounts` (per-row name/type/percent/amount_cents) and `payments` (per-tender method/amount/tip/card info) plus a `change_given_cents` scalar derived from pos_drawer_transactions — genuinely one coherent change since both tickets' data needs route through the same RPC, not forced together.
Alternatives considered
Three independent checkout paths (apps/terminal, apps/pos, the payment-link cron) had copy-pasted the exact same 'type: fixed' hardcode — fixing only the one named in the ticket (apps/terminal) would have left two silently-identical bugs behind for the next person to rediscover. Reusing get_bill_payload_for_order for both TRX-04 (receipt) and TRX-05 (email) keeps the print and email surfaces unable to drift from each other, which is the exact property that function was extracted to guarantee in the first place (20260729111131's own header comment). Every change is additive/nullable — a job or client reading the old shape keeps working unchanged (verified: print-relay's own pre-existing tests pass unmodified against the new renderer).
Detect the Next.js stale-action error (Failed to find Server Action / Server Action '…' was not found on the server) in one shared helper (lib/stale-server-action.ts). A global QueryClient MutationCache/QueryCache onError shows ONE persistent sonner toast ('Setpoint was updated while this page was open. Finish the current sale, then reload to continue.') with a Reload button; useCreateFacilityCustomer skips its own toast for this error so staff never see 'Failed to create customer'. The handler covers every Server Action called through React Query (all terminal mutations/queries); the few actions called directly from event handlers are out of its reach. FOLLOW-UP (not part of this change): enable Vercel Skew Protection on the terminal project (project setting) as the platform-level fix.
Alternatives considered
The data is fine and a reload fixes it, so the message must say exactly that instead of reporting a failed customer. Covered by a Playwright spec that forces a real stale-action response from Next.js (rewriting the Next-Action header) plus unit tests.
Whole-order EXISTS match: a payment matches if its order has ANY line item in ANY selected category — `exists (select 1 from pos_order_items oi where oi.order_id = o.id and oi.category_id = any(p_category_ids))`. The filtered row shows the full order (every line, the real total), never a line-item-truncated view. No recursive parent/child expansion — p_category_ids is a plain, flat array; selecting a parent category does NOT implicitly pull in its children's items. Multi-select is how a 'group' of categories gets filtered at once.
Alternatives considered
Matches how the already-shipped free-text item-name search inside search_pos_transactions works (its own `exists (... oi.item_name ilike ...)` arm) — consistent mental model across every filter dimension on this page: narrow which ORDERS/PAYMENTS qualify, never truncate what's shown once one does. Two implementation details surfaced only while building, not anticipated in the plan: (1) CREATE OR REPLACE FUNCTION cannot add a new trailing parameter without creating a second, ambiguously-overloaded function (PostgreSQL identifies the function to replace by its full ordered parameter-type list) — the migration uses explicit DROP + CREATE instead, verified by querying pg_proc locally that exactly one overload of each function exists afterward. (2) PostgREST's `.or()` filter syntax flatly rejects a dotted embedded-resource column path (`payments.order_id.in.(...)` inside an `or=(...)` logic tree returns PGRST100 "failed to parse logic tree" — confirmed via a direct REST call against local Supabase, not assumed) even though the identical dot-path works fine as a plain, non-`.or()` filter elsewhere in the same file — fetchAllRefundsForExport resolves a payment_id allowlist (a genuinely top-level column on pos_refunds) instead of an order_id one to route around this.
One concrete, fixed finding and one flagged-not-fixed finding; everything else investigated and ruled out. FIXED: create_pos_order_atomic's two still-live SEC-01 RAISE EXCEPTION messages ("this facility requires a PIN-verified identity to close a bill", "bar staff cannot close a bill" — confirmed against the LATEST migration, 20261006103519, not a stale earlier copy; the third, "manager PIN approval" message was removed by that same migration per DEC-490 and correctly left unmapped) were unmatched in BOTH apps' friendly-error.ts, falling through to raw RPC text — the exact "[object Object]"-class incident that file's own header exists to prevent. Added matchers + 2 unit tests in apps/terminal; mirrored in apps/pos. FLAGGED, NOT FIXED (needs Alba's call, not a bug fix): apps/pos retains a fully live, functional /terminal checkout route (own CheckoutDialog + useCreateOrder, drawer-session-gate ported to it as recently as remediation Phase 3) that is unlinked from any nav/UI anywhere in the app — reachable only by direct URL — and has never been wired to pass p_acting_user_id to create_pos_order_atomic. DEC-458 already decided the facility-wide gate must bind EVERY caller once a facility adopts PINs, apps/pos included — but no PIN-entry UI was ever built for apps/pos to satisfy that. Default pin_lock_enabled is false for every facility (DEC-480), so there is no CURRENT production impact, but the first facility to flip that toggle will find every checkout through that orphaned route hard-failing with no way to resolve it in place. Fails CLOSED (no data corruption / security bypass — the server correctly rejects the write), so this is an availability/business-continuity landmine, not an exploitable hole. RULED OUT, with reasoning: (a) void/refund attribution has the identical shared-login-vs-PIN gap (void_pos_order has never been updated to accept p_acting_user_id since 2026-09-18, predating SEC-01) — explicitly out of scope per #1227's own scope notes ("a separate, larger ticket another session is handling") and this sweep's own collision-awareness instructions; left untouched to avoid racing that session. (b) The discount-approval-gate PIN fast-path is a pure client-side unlock with no server-side cap/consumed-verification-row, meaning any direct RPC caller could in principle apply an unapproved discount — but this mirrors the PRE-EXISTING email-code approval flow's exact trust model (same migration's own header comment), is not a new regression from the last 36 hours, and closing it would mean inventing a new "server-enforced discount cap" feature never scoped by this ticket. (c) terminal-lock-provider.tsx's client-side lock state has a well-documented history of fail-open bugs (DEC-459/480/485 and others), but every one is now covered by defense-in-depth: even if the client fails open, create_pos_order_atomic's DB-level gate still blocks the actual bill close without a real PIN. (d) RLS widenings reviewed in the last 10 days (pos_activity_logs to cashier, crm_customer_groups_staff_read, pos_resolve_facility_display_names) are each explicitly tenant-scoped (caller's own facility role re-checked inside the function/policy) and return only display-safe fields — no cross-tenant or over-broad-role leak found. (e) create_pos_order_atomic's tenant checks (location, item_variation_id, category_id, modifier_id, tax_id, service_charge_id, gift_card_id, sold_by) are all independently facility-scoped — no IDOR found on the one SECURITY DEFINER function most of this week's writes funnel through.
Alternatives considered
A sweep's value is in distinguishing a real, exploitable, currently-relevant bug from a theoretical one or someone else's in-flight work — manufacturing low-value findings to justify the sweep was explicitly out of scope. The one fix shipped is small, strictly additive (two new regex branches + tests, zero behavior change to any existing matched case, verified by the automated /code-review pass returning zero findings), and directly closes a real "staff sees a raw Postgres error" gap that WILL fire the moment a facility's PIN-lock stale-cache window (terminal-lock-provider.tsx's documented up-to-2-minute race) or the orphaned apps/pos route is ever hit. The orphaned-route gap is reported rather than silently patched because the right fix depends on a product decision this sweep has no authority to make on its own.
(1) Group selected items by their current catalogue_id client-side and call useBulkMoveToCatalogue once per source group (each call removes only its own items from its own source catalogue's pos_item_catalogues junction row before adding all selected items to the target) — in BOTH apps now (see (3)). (2) The two apps diverge on purpose: apps/pos nulls category_id immediately after a successful inline group switch (mirroring the modal) and reopens the category cell; apps/terminal can't null it (required), so it instead reopens the category cell scoped to the new group WITHOUT clearing, forcing an explicit re-pick while the stale category value sits untouched in the DB until then — mitigated with a toast if the operator dismisses the re-pick without choosing. Bulk group moves (toolbar + the pre-existing per-row "Move to catalogue" kebab action in both apps) deliberately do NOT clear/rescope category on either app, matching the kebab action's pre-existing behavior — only the new inline single-item group edit does. (3) Yes — fixed apps/terminal's useMoveItemToCatalogue/useBulkMoveToCatalogue to sync pos_item_catalogues (delete the old catalogue_id row, upsert the new one), matching apps/pos's existing behavior, and updated every call site including the pre-existing kebab action (not just this PR's new ones) so the fix closes the bug everywhere it's reachable, not just in the new code paths.
Alternatives considered
Reusing the existing, already-tested mutation hooks (useBulkMoveToCatalogue, useMoveItemToCatalogue, useBulkSetCategory) rather than rewriting them keeps this a UI-wiring change, not a data-model change — the per-source-group batching for apps/pos is a client-side loop around the unmodified mutation, and apps/terminal's required-category constraint is respected rather than worked around. A dedicated categoryOptionsForCatalogue fallback (mirrored from item-modal.tsx's groupCategories — explicit pos_catalogue_categories membership plus categories any item already has in that group) scopes both apps' inline category picker to the row's current group, same rule the modal already enforces. Industry comparison, checked by name rather than assumed: Square and Lightspeed Retail both require a category on every item (no "uncategorized" state reachable from their item list) and resolve tax rules through the category assignment, not a parallel junction table that can drift from it — which is the actual bug class this PR's apps/terminal fix closes. Toast (Square's own POS) keeps category OPTIONAL and tolerates "uncategorized," closer to apps/pos's existing (pre-this-PR) tolerance for a null category_id. Neither product's documented behavior independently resolves the apps/pos-vs-apps/terminal divergence here, since that divergence was set by POS-01/02/03 before this PR and is being preserved, not introduced — but the comparison confirms apps/terminal's required-category model matches an established pattern (Square/Lightspeed) rather than being an ad hoc rule, and that a junction table drifting from the field that actually drives tax resolution is the known failure mode those products avoid by not having one.
(1) FIXED: loadCartFromTicket's id assignment changed from unconditionally minting a fresh cli-restored-<counter>-<timestamp> id for every line to reusing item.id when the restored row already has one, falling back to a freshly generated id only for a genuinely id-less legacy/malformed row. The id cart_json already carries is true for every line ever written through buildTicketCartJson — the overwhelming common case — so this is purely additive: nothing else in loadCartFromTicket changed. Re-ran line-edit-attribution.spec.ts after the fix: clean pass, line_ref now matches across the add event and the later remove event for the same physical cart line, across a real save-reset-reopen-save cycle. (2) REJECTED the 'parallelize the pre-read and the update via Promise.all' suggestion: the entire point of reading cart_json BEFORE the update is to capture the true prior state for the diff; racing the two can return the pre-read reflecting post-update data depending on ordering, silently corrupting the diff baseline for a latency win that doesn't matter at this write volume (one save per cashier action, not a hot loop). (3) ACCEPTED as a known, pre-existing limitation (not introduced by this feature): the read-then-write pair has no optimistic-concurrency guard, so two genuinely simultaneous saves of the SAME ticket (two devices/staff editing one open ticket at the same instant) can each diff against a stale 'before' snapshot, producing an attribution-log entry that doesn't perfectly match the final persisted cart_json. facility_tickets.cart_json itself has always been last-write-wins with no version column — every other mutation in this file (split, move, close) already inherits that same race; this diff's accuracy is a cosmetic casualty of the SAME pre-existing gap, not a new one, and a real fix (transactional server-side diff via an RPC, matching create_ticket/create_pos_order_atomic's pattern) is a materially larger change than what 'no database migration needed' scoped this ticket for.
Alternatives considered
The id-regeneration bug was a genuine, confirmed correctness defect in shared code (terminal-store.ts, used by far more than this one feature) that this feature's own diff logic happened to be the first thing to actually depend on id stability enough to expose it — fixing it at the source (not working around it in the diff) is the architecture-first call this repo's CLAUDE.md asks for. The two remaining findings are a case of "not every /code-review finding is a fix": one was actively wrong to apply (parallelizing would break correctness for a latency win nobody asked for), and the other is a pre-existing, already-accepted architectural property of this exact table that a line-attribution feature inherits rather than introduces.
(1) Extended LogActivityInput (apps/terminal/src/app/actions/log-activity.ts) with optional actingUserId/approvedByUserId, written straight through to pos_activity_logs.acting_user_id/approved_by_user_id when supplied, both defaulting to null — every existing caller that omits them is unaffected, and the columns start getting used without a migration since they already exist. (2) useSaveTicketCart (apps/terminal/src/mutations/use-ticket-mutations.ts) now reads the ticket's cart_json BEFORE the update (mirroring this file's own existing fetch-then-write pattern in useCloseTicket/useBatchMoveItemsToTickets), runs both the before and after cart through readTicketCart (packages/common/src/ticket-cart-json.ts) to strip the discount_meta/wrapper-shape noise, and diffs the two CartLineItem[] by `id` — the only stable per-line identity cart_json actually has. The plan's own wording asked for a diff "by line_ref"; cart_json has no such field (that name is already taken by a DIFFERENT concept in packages/common/src/discounts.ts, a discount-application ref keyed to a persisted pos_order_items.id that doesn't exist yet at cart-save time) — CartLineItem.id is reused as the identity and surfaced as `line_ref` only in the emitted activity-log payload, matching the ticket's vocabulary without pretending the data model has a field it doesn't. (3) Three new granular events — ticket_line_added / ticket_line_removed / ticket_line_qty_changed — fire alongside the pre-existing generic ticket_cart_updated (kept, not retired: it's still the only event covering a save with no line add/remove/qty-change at all, e.g. a modifier/note/discount-only edit, which the line diff deliberately doesn't cover). The granular events carry actingUserId from useTerminalLock().identity?.userId (the live PIN-verified operator) while actorId stays `user?.id` (the shared till login) on all events, generic and granular alike — unchanged. (4) activity-logs/page.tsx's ACTION_CONFIG gained entries for the three new actions; it already had a titleized fallback for anything unmapped, so this is a labelling improvement, not a functional requirement.
Alternatives considered
acting_user_id/approved_by_user_id already exist on pos_activity_logs (migration 20260929073234) specifically so SEC-01 follow-up work like this could start writing them without a new migration — leaving LogActivityInput without a way to set them was the actual gap, not the schema. Making the extension generic (both fields, not just actingUserId) costs nothing extra now and is already known to be needed again: the plan explicitly flags that the void/refund attribution ticket (separate, larger, different session) will build on this exact same extension.
"Category" resolves to four REAL, derivable dimensions — discount name (as applied), type (percent vs fixed, two inconsistent spellings normalized to one), scope (bill-level vs item-level, DERIVED from order_item_id IS NULL since pos_order_discounts has no scope column of its own), and source (preset vs manual, DERIVED from discount_id IS NULL). "%" is ALWAYS derived, never read from a stored value, for every row regardless of type — amount_cents / the order's own subtotal_cents for a bill-level discount, amount_cents / the specific item's total_cents for an item-level one — and rendered with a ≈ prefix everywhere, not just for fixed-type rows as the original plan assumed (it believed percent-type rows carried a stored percent; they don't, confirmed by reading every pos_order_discounts INSERT in the checkout RPCs).
Alternatives considered
Reports exist to tell an operator what actually happened, and a percent that LOOKS stored but is silently derived (or worse, derived inconsistently — exact for some rows, approximate for others with no visual distinction) is the kind of thing that erodes trust in every number next to it once someone notices. Labeling every % with ≈, uniformly, costs nothing and tells the truth. Deriving from the order's own subtotal_cents/the item's own total_cents rather than reconstructing a hypothetical pre-discount basis from other tables keeps the definition auditable in one line, matching apps/terminal's own established convention of treating subtotal_cents as the pre-discount gross figure.
INTENDED, NOT A REGRESSION. Once any report page sets a range (preset or custom), useReportDateRange (apps/pos/src/components/ui/date-range-picker.tsx) now restores it from a new sessionStorage-backed store (apps/pos/src/stores/report-range-store.ts) on every subsequent page that uses the hook — Sales, Discounts, Payments, Transactions, every other reports/* page, and the dashboard — until the user picks a different range or signs out. This explicitly supersedes each page's own `defaultPreset` argument (e.g. Discounts' "today", Sales' "last7") for every visit after the first range is set in that session. Precedence is URL-carried range (REP-05's item-sales drill-down state) > the persisted session range > the page's own default — unchanged, nothing about REP-05 regresses.
Alternatives considered
Documented explicitly so a future session seeing Discounts open to "Last 7 days" instead of "today" doesn't mistake restored shared state for a bug — this is the entire point of REP-04, not a side effect of it. The dashboard's inclusion was a scope call made during implementation (it uses the same hook, so excluding it would need special-casing that hook rather than just using it) and is recorded here rather than left implicit.
Investigation found two confirmed bugs, independent of each other: (1) terminal-lock-provider.tsx's visibilitychange→hidden handler called lockNow("idle") the INSTANT the tab reported hidden, with zero duration threshold — on an iPad PWA this fires from a Control Center swipe, a notification banner, or the app-switcher gesture, none of which mean the staff member stepped away, and it was tagged with the same "idle" lockReason the real multi-minute timer uses, so there was no way to tell a real idle-out apart from a half-second flicker. (2) apps/terminal's closed_by_name resolution queried `customers` directly by id, which only matches a user who ALSO has their own booking/order/membership row — a till-only cashier, exactly who closes bills by PIN, never does, so the lookup silently returned nothing and the (already-built, correctly gated) "Closed by" UI line never rendered; apps/pos (the actual back-office Sales Transactions screen) had no closed_by handling at all. Fixed by: (a) giving the visibilitychange trigger a 12s threshold before locking, cancelled if the tab returns to visible first, tagged with its own new "backgrounded" lockReason instead of reusing "idle"; pagehide is deliberately left instant (navigation-away/unload, where a deferred callback may not survive); (b) broadening the activity-reset listeners (pointerdown/keydown only, before) to also include scroll/touchmove/wheel, debounced, so passive use (scrolling a catalogue) counts as activity; (c) swapping the broken `customers` lookup for pos_resolve_facility_display_names — the established fix for this exact gap class (ALB-83, ALB-99) — and extending that RPC's evidence clause (migration 20261007042156) to cover pos_orders.closed_by_user_id/close_approved_by_user_id, which it didn't before; (d) raising the idle-lock default from 3 to 10 minutes (packages/common/src/lock-cadence.ts + migration 20261007043444, which also backfills any facility still sitting on the never-actually-confirmed 3-minute placeholder) — "lock after every bill" already re-locks at the moment that actually matters, so the idle timer only needs to be a backstop. Verified via a real e2e run (not just code reading): pin-lock-screen.spec.ts extended to open the real Transactions list and assert "Closed by <name>" actually renders after a PIN-verified close, not just that closed_by_user_id is set in the DB.
Alternatives considered
Both bugs trace to specific file:line citations from a 3-agent parallel investigation (idle-lock root cause, attribution-surfacing gap, full feature inventory), not guesses — and both were verified fixed against real code/DB state (a real e2e assertion that the UI actually renders the resolved name, not just that the RPC exists). This is phase 1 (P0) of a larger improvement plan Alba reviewed and approved ("go for it"); phase 2 (porting closed_by display to apps/pos + a Transactions list column in both apps) and later phases (Sales Report closed-by breakdown, Settings activity visibility) follow as separate PRs. The plan's own opening argument — industry POS systems (Square/Toast/Clover) treat the PIN as an input to reporting/accountability, not just a till gate — motivates why attribution-surfacing is being treated as equally urgent as the lock bug itself, not a nice-to-have.
REMOVED FROM THE NAV AS INSTRUCTED, NOT DESTROYED. The Sessions tab is gone and there is no Sessions nav entry. Its component (AcademySessionsLedger) is re-mounted on the new Billing screen as a COLLAPSED "Package deductions" section, and its API route plus _lib (api/admin/package-transactions/route.ts, _lib/ledger.ts and ledger.test.ts) are completely untouched and still tested. A link carrying ?tab=sessions redirects to /academy/billing?section=deductions, which arrives with the section already expanded. The premise behind the instruction is half right and the half that is wrong is the expensive half: Academy → Lessons is the SCHEDULE — what is coming up. This ledger is the record of deductions ALREADY TAKEN: which package paid for each past session, how many were left afterwards, and — the part Lessons cannot show at all — which lessons were cancelled without the student's credit being returned. On production that is 15 unambiguous cases plus 4 ambiguous ones, i.e. money the club kept by mistake. Deleting the UI would have removed a capability while looking like removing a duplicate.
Alternatives considered
The capability at stake is the one that finds money the club wrongly kept, which is exactly the kind of thing that is never missed until someone disputes a balance and there is no longer anywhere to look. Collapsing it rather than deleting it honours the nav instruction (it is not in the menu, and it is not in the way) while keeping the answer one click from the screen where the question gets asked. The route and _lib being untouched also means it can be re-promoted to its own destination in one change if he wants it back.
(a) BILLING GETS /{facility}/academy/billing, NOT /academy/payments. The nav label is Billing, and this nav has already paid for letting the label and the address disagree: v0.52.0 relabelled an entry "Members" while leaving its route at /academy/subscriptions, and the fix (2026-09-17) was to move the route so the two agreed. Shipping a screen labelled Billing at /academy/payments would recreate that defect deliberately. /academy/payments stays what it has been since it stopped being a page — a permanent redirect — now aimed straight at /academy/billing rather than bouncing through Members. (b) ?tab= IS NOW A ROUTING DECISION, READ SERVER-SIDE, ONCE. All three paths that can receive it (/academy/members, /academy/payments, /academy/subscriptions) share one helper, src/lib/forward-search-params.ts: `payments` and `sessions` redirect to Billing, everything else resolves to Members, the dead `tab` key is stripped at the redirect, and every other param survives verbatim including repeated keys. `sessions` additionally arrives as ?section=deductions so the ledger it asked for is open. (c) THE MEMBERS PAGE ONLY BOUNCES A BILLING TAB. ?tab=members, no tab, and any unrecognised tab render in place — they already resolve to that screen, so a redirect would be a wasted hop. (d) withDefaultTab IS RETIRED, its job absorbed as an options.defaultTab on the new dispatcher, so /academy/payments still means the money screen when the caller names no tab, and an explicit ?tab=members on it still wins.
Alternatives considered
A link that 404s is obvious and gets reported; a link that silently lands on the wrong screen is neither, and this exact screen has already shipped that regression once (DEC-460, where the tab reorder would have retargeted every /academy/payments bookmark). Making the tab key a routing decision in ONE pure function means the whole map is pinned by unit tests and verified at the HTTP layer rather than being three similar branches that can drift. Verified: 20 unit tests on the helper, plus 19 assertions driven through real Chromium against a dev server reading the actual Location headers — every entry point, repeated keys preserved, tab= always stripped, and both destinations answering 200 directly with no chain.
Investigated the full history (PR #1188 → incident/revert #1203 → rebuild #1204, and DEC-453/455): the mandatory manager-PIN-approval gate on EVERY close (even for a manager themselves) was logged as a deliberate rule attributed to Alba ("only a manager can close"), but (a) no original ticket/brief document exists anywhere in the repo to source that claim against — it only ever appears as the building agent's own paraphrase, (b) PR #1188 was explicitly marked "do not merge until Néstor approves via Alba" and was merged anyway with no visible written approval in the thread, (c) it broke production checkout for ~35 minutes within 43 minutes of merging, requiring an emergency revert, and (d) the reviewer (Néstor) flagged it as a real operating-model change ("a manager has to be physically on the floor every shift") that was never explicitly re-confirmed before the rebuild re-shipped the same core rule. Researched how Square/Toast/Clover/SpotOn actually do this: all of them reserve manager-PIN approval for sensitive overrides specifically (voids, refunds, discounts above a cap, no-sale drawer opens) — none of them gate a routine bill close behind manager approval; a normal close is authenticated only by the operator's own PIN (for attribution), exactly what Alba originally asked for. Removed business rule 3 entirely (client: checkout-dialog.tsx's ManagerPinSheet gate on handleComplete/handleCompleteSplitPayment; server: create_pos_order_atomic's p_manager_pin_verification_id requirement, migration 20261006103519). Business rule 2 (bar staff can never close) and the operator-attribution half (p_acting_user_id / closed_by_user_id, stamped from the PIN-login identity) are both fully unchanged — those were never in question.
Alternatives considered
The gap between "logged as Alba's own rule" and "never actually sourced to a real written requirement, merged against an explicit do-not-merge hold, and broke production once already" matters for how much weight to give an unsourced decisions.ts entry going forward — a PR description's or decision log's own claim of user authorship is not itself proof, especially when it was never checked against a real ticket/brief. Named industry precedent (Square/Toast/Clover/SpotOn) directly contradicts the removed rule's design and supports the simpler, originally-requested behavior.
It was neither TerminalLockGate's flash/gate logic nor the close-gate's business logic (handleComplete's actual branching — bar-staff block, pinLockActive fail-safe, setPendingComplete — was already correct and unchanged). It was pure CSS stacking. checkout-dialog.tsx's own root is `fixed inset-0 z-[70] bg-background` — a deliberate, documented pattern in that same file: every OTHER overlay opened from inside the full-screen checkout (EmailCaptureDialog, CreateCustomerSheet's popovers, even a `<Select>` dropdown in keypad.tsx, which carries its own comment explaining exactly this) uses a custom z-index ABOVE 70 for precisely this reason. ManagerPinSheet (manager-pin-sheet.tsx) was the one exception: built on the shared `<Dialog>`/`<DialogContent>` primitive, whose default is a plain z-60 (ui/dialog.tsx). z-60 < z-70, so every time handleComplete opened it with PIN-lock on, it mounted correctly, in the right state, with `open=true` — and painted entirely behind the opaque, full-viewport checkout screen. A cashier tapping "Mark as Paid" saw literally nothing change, indistinguishable from a dead button. Confirmed by reproducing it in a real browser (screenshot evidence in the PR, matching Alba's report pixel-for-pixel) and by a deterministic check of the two elements' actual computed z-index (60 vs 70) — not by trusting any `isVisible()`/`toBeVisible()` assertion, which passed even on the broken code (see rationale). Fix: `DialogContent` now accepts an `overlayClassName` prop so a caller can override the overlay's z-index too (previously hardcoded, not overridable at all), and `ManagerPinSheet` passes `z-[80]` on both the content and the overlay — the minimal bump clear of checkout's z-70, matching the file's existing convention rather than inventing a new one.
Alternatives considered
The investigation's first and strongest lead going in was the OTHER PIN surface, TerminalLockGate/DEC-487 (built in the same PRs today) — it wasn't that; the actual bug was in a component neither of those PRs touched at all, found only by reproducing the literal reported steps in a real browser rather than reasoning from the most recently-changed code. Second, and more important for this repo going forward: a Playwright `isVisible()`/`toBeVisible()` check on the sheet PASSED even on the broken build — it only inspects the target element's own CSS (display/visibility/opacity/size), never whether something else is painted on top of it — so the existing e2e suite's `approveManagerPinSheet` helper (and every spec built on it) could not have caught this class of regression at all, with or without this fix, and intermittently a coordinate-exact Playwright click even completed the full flow end-to-end on the broken build purely by timing luck (CSS not yet loaded at first paint). The new regression test added for this fix (`pin-lock-screen.spec.ts`, "QR Code (payment_link) Mark as Paid...") therefore asserts the actual computed z-index relationship between the two elements directly, in addition to driving the real PIN pad to a DB-verified closed order — matching DEC-487's own standing lesson that a visibility/timing claim needs more than a passing DOM assertion, not just reasserting it in English.
Re-examined the screenshot pixel-by-pixel: a real OS-drawn translucent blur overlay (the #1212 theory) should show color/content bleed-through, like frosted glass. What's actually in the screenshot is uniformly soft text and icons with NO translucency — the signature of a different, well-documented Flutter web bug class instead: CanvasKit/Skia's rendering surface can end up with its raster buffer size mismatched from its CSS display size (flutter/flutter#122541, #126795 — "canvas size and the CSS size are off by one"), forcing the browser to resample the canvas instead of showing it 1:1. That specific historical bug was fixed upstream in early 2024, before this app's Flutter version (3.44.1) — but the same CLASS of mismatch can recur any time the real viewport settles AFTER Flutter's first paint, which is exactly the sequence of launching a standalone iOS PWA (status bar / safe-area / address-bar-hidden layout resolves asynchronously after launch, unlike a stable browser tab). Fix (#1214): dispatch a synthetic `resize` event — what's known to self-resolve this bug class on desktop (manually resizing the window) — once on Flutter's own `flutter-first-frame` readiness event, plus once more 500ms later to also catch iOS's own post-paint safe-area settling. Gated to iOS only (`navigator.platform` sniffing, same approach Flutter's own web engine uses internally) so Android/desktop never pay for it.
Alternatives considered
Marked as a heuristic mitigation, not a guaranteed root-cause fix: there is no iOS device available in this environment to reproduce the bug or verify the correction actually happens, only web research and a match between the bug's documented symptom and what Alba's screenshot shows. Named precedent checked per this repo's self-debate rule: the flutter/flutter team's own fix for the closest related historical bug (#122541) was an engine-level change forcing a recompute tied to the next real resize event — this app-level mitigation mirrors that exact mechanism (force a resize) since the actual engine-level fix isn't something that can be backported into an app from JS. If this doesn't resolve it, the next step is getting a real device trace (e.g. Safari remote debugging a connected iPhone) to directly inspect the canvas element's actual vs. CSS dimensions at launch, rather than another round of theory from a screenshot alone.
The facility route tree is server-rendered (app/(dashboard)/[facility]/layout.tsx is an async Server Component). localStorage does not exist on the server, so the very first HTML the browser paints — before any client JS has loaded, let alone hydrated — can only ever be the fail-closed default: locked. DEC-485's cache made the client CORRECT that a moment later, which is the flash itself, not a fix for it. No client-side state logic can change what the server already sent and the browser already painted. The actual fix: TerminalLockGate now withholds a committal answer (locked or unlocked) until the client has mounted — `useState(false)` + `useEffect(() => setMounted(true), [])`, returning `null` while `!mounted`. Server and client render IDENTICALLY on first paint (nothing to flash, no hydration mismatch); DEC-485's cached value is already resolved synchronously by the time `mounted` flips true, so the reveal goes straight to the right answer.
Alternatives considered
An earlier DOM-based MutationObserver check (cited in DEC-485 as verification) gave a false negative on this exact bug — it claimed the lock screen never rendered when it demonstrably did, visible in Alba's own words and in the demo video she was shown. Established going forward: a sub-second flash claim is only trusted after extracting and inspecting actual video frames, not a passing DOM assertion. Fixing this also surfaced a real race condition in the e2e helper `unlockTerminalIfLocked` — the new mounted-gate creates a brief window where neither the lock screen nor real content exists in the DOM, and an instantaneous `isVisible()` check could land exactly there and wrongly conclude "not locked"; fixed with a 300ms settle wait before checking.
useFacilityPinLockActive's last-confirmed answer is now persisted to localStorage (keyed per facility) and handed back as the query's placeholderData — a synchronous, zero-network starting value a returning device already has the instant it boots. For a facility that's had pin-lock off for a while (the case this was reported about), the lock screen now has no reason to ever render on a normal boot: "off" is the free, already-known default, not something that has to be earned fresh via a network round-trip every time. The real fetch still always runs in the background and corrects this if it's actually wrong (e.g. a manager just turned the toggle on from elsewhere) — this only removes the unnecessary network DEPENDENCY for the unchanged, common case. Verified directly: a returning session with a cached "off" value never renders the lock screen at all, confirmed via a MutationObserver watching for it across a second boot, not just checking the end state.
Alternatives considered
Matches the standing lesson this whole feature already carries, now at the architecture level instead of the implementation level: the four prior same-day fixes were each individually reasonable, but collectively they were evidence of patching the wrong layer. The cached-default approach is the one that actually answers Alba's question — a toggle-off facility genuinely has nothing left to gate behind a network call once it has one confirmed answer, so it shouldn't be gated behind one.
Fix each writer so it changes lesson + bookings in ONE transaction: update_lesson_status_for_staff and update_lesson_and_bookings_status cancel live legs through set_booking_outcome (refund rules + audit event), the admin court change moves into replace_lesson_courts, and the cancelled-lesson 409 guard is extended to the students check-in route. Cancelled lessons cannot be reopened through these RPCs. A read-only view lesson_booking_orphans defines 'orphan' for monitoring. KNOWN GAP (follow-up): these RPCs do not refund package sessions the way cancelLessonCascade does. A trigger on lessons.status is NOT shipped — left for Alba's call.
Alternatives considered
The calendar PWA already cancels atomically since 2026-10-01; the remaining sources were the stale RPC (still reachable from cached builds), the non-transactional admin court change (partial failure = lesson booking without a link), and an unguarded check-in route. Verified by TC-300 plus a rolled-back SQL scenario run for each RPC.
Stop chasing the exact mechanism and reproduce the fix that's already confirmed to work, every time, by hand: force-quitting and reopening the app IS a full page reload — the most reliable reset there is, since it re-initializes everything (auth session, every query, the whole provider tree) from scratch with no code path left that can leave any piece of state stuck. terminal-lock-provider.tsx now does this automatically: on resuming from having been hidden for more than 30 seconds (targeting an actual device sleep, not a quick glance away), it reloads the page — but ONLY when the cart is empty. The cart lives in a plain, non-persisted Zustand store, so reloading with items in it would silently wipe an in-progress sale — a new, worse problem than a stuck lock screen. The common case (till idle, no sale in progress when the device slept) self-heals completely automatically; a till mid-sale is deliberately left alone, accepting that the rarer case could still need a manual kill rather than ever risking a lost sale. Verified directly: an empty-cart till reloads after a simulated 31s-hidden period; a till with an item in cart does not reload and the item is still there afterward.
Alternatives considered
Matches the standing lesson this whole feature already carries: verify by actually reproducing the failure and the fix, not by reasoning about what should theoretically work. The reload-only-when-empty rule is a deliberate, explicit trade-off — protect the dangerous case (never lose a sale) absolutely, and be maximally decisive for the safe case rather than leaving it half-fixed.
Layered fix, no single silver bullet: (1) useFacilityPinLockActive's staleTime cut from 2 minutes to 10 seconds — bounds any gap to a short, practical window; (2) refetchOnWindowFocus added — covers the actual scenario that happened, switching from the Settings tab back to the Terminal tab refetches immediately, using React Query's own built-in behavior, nothing new to verify; (3) checkout-dialog.tsx's performComplete/performCompleteSplitPayment now catch this exact server rejection (matched on the RPC's error message, not instanceof Error — the RPC throws the raw Postgrest error object, not a wrapped Error) and recover: invalidate the stale query, show a clear actionable toast ('Security settings just changed — tap to charge again'), so the very next tap succeeds instead of needing a reload to unstick. Verified end-to-end: a stale client's first charge attempt is cleanly rejected with the friendly message (no unhandled rejection), and the second attempt correctly routes through the manager-PIN gate and completes. New permanent e2e regression test covers exactly this sequence.
Alternatives considered
Built-in-first (CLAUDE.md's own standing bias): React Query's staleTime and refetchOnWindowFocus are well-tested, zero-new-infrastructure mechanisms that directly cover the realistic failure scenario. The error-recovery catch is the genuine safety net for whatever gap remains (e.g. a different physical device where no window-focus event ever fires) and was confirmed working through direct reproduction, not assumed. Everything here was found and fixed via actual reproduction in a local browser, not by reasoning about the code — matching the lesson this whole rebuild already carries from the original incident.
Manual stays the default; a small "Generate a random PIN instead" link/button sits below the PIN pad. Tapping it generates and assigns immediately (no second confirm step) and swaps the dialog into a reveal view — large PIN, Copy PIN, Done — using the same one-time-reveal wording and clipboard pattern as the existing bulk-generate flow. Extracted the retry-on-collision loop both flows need into a shared assignRandomPin() helper rather than duplicating it.
Alternatives considered
Planned by Fable (full plan reviewed before implementation), implemented by Sonnet. No backend change — reuses pos_set_staff_pin verbatim, same manager+ check, same hash-only/never-retrievable guarantee as every other PIN in this system. Re-reveal after closing the dialog is impossible by design (hash-only storage) — accepted trade-off, identical to the bulk flow's existing behavior, not a new gap this introduces.
No — two real bugs surfaced only through manual + e2e testing. (1) Flipping the switch ON force-relocked the CURRENT session immediately, because TerminalLockProvider is mounted at the facility layout level (wraps every route, Settings included) — a manager turning the feature on got yanked out of the Settings page they were using to configure it. Fixed by removing the force-relock-on-adoption transition entirely; propagation is now natural (a fresh navigation, or DEFAULT_LOCK_CADENCE's own lock-after-bill/idle defaults), never forced on an already-mounted session. (2) checkout-dialog.tsx's manager-PIN sheet still unconditionally appeared with the toggle off, contradicting the Settings page's own 'bills close without a PIN' copy — handleComplete/handleCompleteSplitPayment now check the provider's new pinLockActive signal and skip straight to performComplete(method, null) when it's explicitly false.
Alternatives considered
A thorough plan, even one produced by independent research + design + synthesis + adversarial critique, is still a hypothesis until it's actually exercised. The critique pass separately caught a real cache-invalidation gap (DEC-480) before any code was written; these two bugs are the reminder that design-time review and runtime testing catch different classes of defect and neither substitutes for the other.
One boolean column, pin_lock_enabled, on pos_terminal_security_settings (the existing per-facility terminal-security table — not facilities, not a new table). DEFAULT false for new facilities (opt-in, matching Square's 'require passcodes at point of sale' — a bolt-on toggle over an identity system that doesn't otherwise require a PIN, the closer analog than Toast/Clover where PIN login IS the identity system with no off-state — and matching this repo's own checkout_tips_enabled/checkout_offline_enabled precedent of optional features defaulting off). A new RPC, pos_facility_pin_lock_active(p_facility_id), returns EXISTS(pos_staff_pins) AND pin_lock_enabled — the single combined signal the client reads; pos_facility_has_staff_pins is left untouched for its own literal 'has any PIN rows' meaning, since nothing else needed its meaning changed and a future admin/export tool might. create_pos_order_atomic's gate keeps its own INLINE copy of the same two-clause predicate rather than calling the new RPC — that RPC raises when the caller has no resolvable facility role (correct for an authenticated client read), which would be a new failure mode for the checkout RPC's own direct callers (apps/pos included) that this change must not introduce.
Alternatives considered
Reuses the one table that already owns this feature's config, with its existing RLS/audit columns verbatim — zero new schema surface beyond one column and one function. The default is a deliberate, industry-grounded choice rather than a value that happens to preserve the status quo without reasoning. Produced by a 9-agent research/design/synthesis/adversarial-critique workflow (3 independent design proposals, each self-debated, then scored and merged) — see DEC-481 for the two real bugs testing still caught afterward.
A new SECURITY DEFINER RPC, pos_facility_has_staff_pins(p_facility_id) — callable by any authenticated facility role, not just manager+, since it has to answer BEFORE a PIN identity exists — tells TerminalLockProvider whether the facility has adopted SEC-01 at all (any pos_staff_pins row). While that answer is false, the provider passes straight through (locked stays false, matching today's pre-SEC-01 behavior exactly); it fails closed (locked=true) while the answer is still loading, and the moment the facility's row count flips to "has PINs", the provider force-locks immediately rather than waiting for the next idle/bill trigger. Rollout runbook unchanged otherwise: a manager+ signs into apps/terminal on their OWN device/session (not the shared till) to set PINs for every staff member before enabling this for real tills.
Alternatives considered
Keying the client's pass-through decision off the exact same 'any pos_staff_pins row for this facility' definition used by the create_pos_order_atomic gate (DEC-458) means there is one source of truth for 'has this facility opted into SEC-01', not two independently-drifting checks. Fail-closed while loading, fail-open only once proven zero rows, keeps the default posture safe without reintroducing the rollout deadlock.
Enforce it facility-wide: a new check resolves v_facility_has_staff_pins (EXISTS any pos_staff_pins row for p_facility_id) before the existing gate, and raises if that's true while p_acting_user_id is NULL. Once a single facility has set up even one staff PIN, EVERY caller creating an order there — apps/terminal, apps/pos, or any future direct caller — must supply a verified p_acting_user_id; there is no more opt-out. A facility that has never adopted staff PINs sees zero behavior change.
Alternatives considered
Matches this repo's own precedent for this exact class of problem (VOID_ORDER_ROLES / pos_orders_prevent_completed_mutation — 'the frontend list is cosmetic, the DB is the real gate') one level deeper: the DB gate itself has to be unconditional once adopted, not just present. Keying enforcement off the FACILITY's adoption state (not the caller's choice) also gives the client-side rollout fix (DEC-459) the same single definition of 'has this facility opted in' to pass-through against, instead of two independently-drifting checks.
Resolve names with a second, explicit query against public.customers (id IN (closed_by_user_id, close_approved_by_user_id)) inside useOrder(), attaching closed_by_name/close_approved_by_name onto the returned object — not a PostgREST embed.
Alternatives considered
customers.id already equals the corresponding auth.users.id for staff members — the same assumption pos_verify_staff_pin's own JOIN public.customers c ON c.id = sp.user_id already depends on and that's already proven to work in production. A second explicit query is a few extra milliseconds on a detail-modal open, not a hot path, and keeps the FK pointed at the actual identity table (auth.users) rather than distorting the schema to work around a client-library limitation.
PIN entry is the default (first thing shown) when the gate renders, reusing the same PinPad + pos_verify_manager_pin RPC as manager-pin-sheet.tsx. The original approver-picker → email-code flow survives unchanged as an explicit fallback, reachable via a "No manager here — send a code by email instead" link, with a matching link back from the email flow's approver-picker step.
Alternatives considered
Matches the same instant-step-up shape already established for void/refund via manager-pin-sheet.tsx, so the fast-path has a single, already-tested RPC and UI primitive behind it instead of a new one. Keeping email reachable (not deleted) preserves the one case PIN genuinely can't cover.
Built and committed, in this order: (1) the full additive data model + RPCs + create_pos_order_atomic patch, manually functional-tested end to end against local Postgres before committing (caught and fixed two real bugs — see DEC-451); (2) packages/common pure helpers (PIN format, lock cadence) with unit tests; (3) the PIN identity provider, lock screen, PIN pad, manager-PIN sheet, and their wiring into the till route; (4) the manager-PIN close gate + bar-block wired into checkout-dialog.tsx (business rules 2 and 3 — the actual core ask). Stopped there and reported status rather than also building the discount-approval-gate PIN fast-path, the bill-detail activity-log UI, the Settings > Staff & security page, and e2e tests in the same pass.
Alternatives considered
The data model is the highest-risk, hardest-to-get-right layer (hashing/lockout/atomicity — see DEC-451/DEC-452) and the one every other surface depends on, so it went first and got the most scrutiny (manual functional testing, not just typecheck). The close gate is the ticket's actual headline behavior (business rules 2/3) and the one place a design reviewer would want to see working. The remaining pieces (discount fast-path, activity log, settings page) are each self-contained, lower-risk, and don't block evaluating the core decision this ticket is gated on.
Manager-driven only for this pass: pos_set_staff_pin requires the caller to hold owner/admin/manager (or be service_role); there's no path for a staff member to set their own PIN. Settings > Staff & security (not yet built in this pass — see DEC-455) will be the only place a PIN gets assigned or reset.
Alternatives considered
Matches the prototype's actual clickable behavior over its prose description where the two disagree, keeps the ticket's scope aligned with its own explicit 'what to build' list, and is trivially extensible later (a self-serve first-login flow could call the exact same pos_set_staff_pin RPC, just from a different UI entry point, gated to self-target instead of manager-target) without any schema change. Flagged in the build report for Alba to confirm this reading is correct.
Closing NEVER gets the shortcut — every close opens ManagerPinSheet and requires an explicit PIN entry, even when the operator who tapped Charge is themselves owner/admin/manager. This is implemented as the literal absence of an auto-approve branch in checkout-dialog.tsx's handleComplete/handleCompleteSplitPayment (unlike, say, a future discount fast-path, which per the ticket text explicitly CAN auto-approve for a manager+ operator) and mirrored server-side: create_pos_order_atomic's gate has no 'skip if p_acting_user_id is already manager+' branch either — it unconditionally requires and consumes a fresh pos_manager_pin_verifications row whenever p_acting_user_id is set.
Alternatives considered
Re-entering a PIN when you're already a verified manager might look redundant, but it's the whole point of the rule: it converts 'the shared session happens to be logged in as someone privileged' into 'a specific, physically-present person just confirmed this exact close, right now' — indistinguishable from the case where a DIFFERENT operator is currently acting. Confirmed real-world precedent for this exact pattern (deliberate re-auth on a sensitive action even for an already-privileged session) in Square/Toast-class POS systems, which the prototype cites as its own reference class.
pos_staff_pins.is_bar boolean NOT NULL DEFAULT false — a single flag, not a station enum. Per-facility, per-staff-member (same granularity as user_facility_roles), toggled independently of resetting a PIN via its own RPC (pos_set_staff_bar_flag).
Alternatives considered
The boolean captures exactly what business rule 2 requires and nothing more. If a real 'kitchen'/'reception'/other station concept becomes needed later (e.g. POS-08's price-override ticket, or a future printer-routing-meets-staff-roster feature), it's a normal additive migration to widen is_bar into a station enum or add it alongside — not a redesign. Flagged explicitly in the build report as a scope call worth Alba/Nestor's confirmation, per the ticket's own instruction to flag this exact decision.
SHA-256, unsalted, via Postgres's existing pgcrypto extension (`encode(extensions.digest(p_pin, 'sha256'), 'hex')`) — identical to pos_discount_approvals.code_hash. A straight UNIQUE(facility_id, pin_hash) constraint enforces 'PINs are unique within a club' natively, which an unsalted hash makes possible without a loop-compare (a salted hash would need one, since the same PIN produces a different hash per row).
Alternatives considered
The real control here is server-enforced lockout (pos_pin_lock_state — 5 wrong PINs cools the till down 30s) plus facility scoping (a PIN is only ever compared against that one club's roster, never globally) — i.e. the ONLINE, rate-limited path, not the hash algorithm. If pos_staff_pins is ever exfiltrated, PINs are trivially recoverable regardless of hash choice, since the keyspace is only 10,000 values either way; hash algorithm choice doesn't change that fact, so matching the repo's existing, simpler convention won out. Explicitly logged per CLAUDE.md's 'self-debate before locking decisions' rule — this is exactly the kind of choice that rule exists to force a second look at.
DELETED, all three files: lib/academy-period.ts, lib/academy-period.test.ts and components/admin/academy-period-filter.tsx. Billing, Sessions and Lessons take MAIN's period hunks verbatim — the shared <DateRangePicker>, the Month-to-date default, the reset/isDefault rule — and this branch keeps only its NON-period hunks on those three files: the settled-row gating (DEC-457), the Due/Paid/All switch defaulting to All, and the tab pinning (DEC-460). Enrollments is the one screen the picker did not reach — on main it had no date filter at all, which DEC-432 says in as many words — so it gets `useDateRange(timezone)` + `<DateRangePicker … testIdPrefix="player-subscriptions">` HERE, together with the route's interval-overlap filter (DEC-458), because the control and those semantics are only safe when they arrive together. 🔴 NOTHING WAS REIMPLEMENTED: after this rebase there is no second resolver and no second picker anywhere on this branch. The date arithmetic this branch measured and tested survives as lib/date-range.ts, ported case for case and extended from 24 tests to 32 upstream.
Alternatives considered
The criticism of this branch was never its date arithmetic — that was adopted wholesale, which is why the resolver's timezone handling and its pastOnly clamp are still what ships. It was the presentation, and a branch does not get to keep a control the product has just standardised away from. Deleting all three files rather than adapting them is what guarantees exactly one picker and one resolver in apps/admin after both merges land.
On SETTLED_STATUSES (paid, cancelled, expired), the one list the server refuses on, moved to lib/payment-request-status.ts — a leaf module with no imports — and re-exported from payment-requests/_lib/reminders.ts so every existing import path is unchanged. The client imports the same constant, so the two cannot drift because there is only one. 🔴 IT CANNOT BE GATED ON THE FEED’S OWN status: requestStatus() maps an EXPIRED request to feed status ‘overdue’ (an expired hosted checkout means the LINK lapsed, not the debt), and ‘expired’ is a settled status — so gating on the feed vocabulary would leave exactly the rows the server refuses still being offered a reminder. The feed row therefore carries a new request_status field with the raw payment_requests.status, null on the two sources that have no request. Null is explicitly NOT settled: an unpaid package nobody has chased yet is the row a reminder SHOULD be offered on, since sending one is what creates the request. Hidden rather than disabled, matching the Assign button. Reminder history stays (a READ, and a legitimate thing to want on a paid package); Revert to due stays (it applies BECAUSE the row is settled). The reminder panel itself is also gated — it is reachable on a settled row via Reminder history, and a row can settle while it is open — so its channel picker and Send button are withdrawn and handleSend refuses with a toast rather than silently.
Alternatives considered
The defect was never that something wrong was sent; it was that the product offered an action, the server quietly declined, and the screen said nothing. Defaulting the Due/Paid/All switch to All (DEC-460) makes this materially more important rather than less: paid rows are now in front of the operator by default instead of only after they switch filters.
OVERLAP, not start date: starts_at <= periodEnd AND expires_at >= periodStart, resolved through facilityDateRangeBoundaries at the club’s midnight and applied on the query builder so it lands BEFORE count: ‘exact’ and before .range() — total and page describe one set. ‘Month to date’ therefore means ‘was enrolled at some point in that window’, not ‘signed up inside it’.
Alternatives considered
Both columns are NOT NULL in the type AND hold zero nulls across all 207 production rows — checked specifically, because a null on either side of a range comparison drops the row silently rather than erroring, which would have reproduced the very class of invisible-filter bug being fixed one filter over (DEC-459).
Two fixes, and the second is the one that matters. (1) The chips become Active · Expiring soon · Exhausted · Expired · Cancelled, ordered by the life of an enrolment and labelled explicitly rather than via status.replace, which gave lower-case ‘expiring soon’ beside capitalised siblings. (2) status and payment_status are now WHITELISTED and answer 400 on an unrecognised value, using the same parser contract as product one filter over on the same route (undefined = no filter, null = reject). They were previously a bare .eq() on whatever arrived, so an unknown value returned an EMPTY LIST — which is exactly why a missing chip was invisible instead of loud. The whitelist mirrors player_subscriptions_status_check (all seven legal values), NOT the five chips: a filter on a legal column value should return an honest empty list, not a 400, and a whitelist derived from the schema cannot go stale against the data the way a hand-picked list would.
Alternatives considered
The scale is what makes this more than a cosmetic gap. Census, read-only on production 2026-09-18: active 111, EXHAUSTED 73, cancelled 13, expired 10. Two of the four states a row may hold — 86 of 207 enrolments, over a third of every club’s book — were unreachable through the filter, and a used-up session bank is the single most operationally interesting row on this screen because it is who to sell to next. Each chip was then re-verified per facility under the new default period: pad-thai-padel active 81 / exhausted 64 / cancelled 9 / expired 1, bali 21 / 9 / 3 / 0, warehaus 8 / 0 / 0 / 0 — every chip returning its full all-time count, so the Month-to-date default costs the landing tab nothing today.
Order becomes Enrollments → Sessions → Billing, Enrollments becomes the default tab, the money tab is relabelled Billing, and its Due/Paid/All switch defaults to All. 🔴 THE TAB KEYS AND TESTIDS ARE UNCHANGED — members is still Enrollments and payments is still Billing — because ?tab= is a published deep link that both /academy/subscriptions and /academy/payments forward verbatim, and the Enrollments tab already carries this scar once (its key stayed members when it was relabelled). onTabChange now derives ‘which tab owns the bare URL’ from DEFAULT_TAB rather than hardcoding payments, which after the reorder would have inverted what a bare link means.
Alternatives considered
The reorder is a one-line change; the redirect is the part that could have gone wrong invisibly, which is why it is pinned by three unit tests (pins when absent, never overrides an explicit tab, preserves every other param including ?open=<payment_request_id>, which is what a payment-request notification links to). Prose naming the old tab was updated in both redirect pages, the sidebar and the staff-docs Academy overview so the documentation does not keep describing a screen that no longer exists under that name.
Fix it here, in the route, by adding `.eq('facility_id', facility_id)` to the template read — the facility that requireFacilityAccess has already authorised one screen up. A foreign or unknown package_id now returns 404 with NO row written, and the two cases are deliberately indistinguishable in the response: saying which it was would confirm that some other club owns that id. TWO THINGS THE OBVIOUS FIX DOES NOT COVER, both checked rather than assumed. (1) `package_items` CANNOT take the same filter — it has no facility_id column at all, verified against production's information_schema and not merely against the generated types: its columns are id, package_id, class_id, class_type, session_count. Its scoping is TRANSITIVE through package_id, and the template read above is what proves that package_id belongs to this facility. That is the rule satisfied one join away, not an exception to it, and the code says so — including the warning that moving this read above the template read would silently un-scope it. (2) `assign_package_with_credits` has the SAME class of hole and is NOT fixed here, deliberately — see the rationale.
Alternatives considered
The hole and its sibling were both read directly out of production, read-only. `lesson_packages` has facility_id; `package_items` does not — which is why the fix is shaped as one scoped read plus one documented transitive read rather than two identical filters, and why 'add .eq(facility_id) to both' would not have compiled against the real schema. The RPC was then audited from its LIVE definition via pg_get_functiondef rather than from a migration file that may have been superseded: assign_package_with_credits never reads lesson_packages at all. It takes p_package_id on trust, INSERTs player_packages with the caller's p_facility_id, and populates player_package_sessions from `package_items WHERE package_id = p_package_id` — so it would grant another club's session banks under this club's facility_id. It also takes p_price_amount as a parameter, so the price charged to the wallet is the caller's assertion rather than the template's. Both are real, and both are currently UNREACHABLE from the application: grep across apps/, packages/ and protocol/ finds exactly one caller of that RPC — this route — and this route now 404s on a foreign package_id BEFORE the credits branch is entered. So the route fix closes the only live path today, and the RPC remains a latent hole for any future caller. That is the honest statement of it, and it is why this is reported rather than quietly left as fixed.
Surface it, and make the two empty states visibly different, because that difference is the entire point: a destructive-styled message naming the failure when the fetch throws or returns non-OK, and a plain 'This club has no active packages to assign.' when the fetch succeeded and the list is genuinely empty. A non-OK response is now thrown rather than parsed as data, so a 500 can no longer arrive as `packages: undefined` and read as zero packages. Styling reuses this file's own searchError idiom (border-destructive/30 / bg-destructive/5) rather than inventing a third error treatment inside one dialog.
Alternatives considered
Same rule as the ledger's failed-read path and the Enrollments fetch that was fixed in DEC-382: an error must never render as a legitimate-looking empty result. The distinction is cheap to state and impossible to infer, so the screen states it.
No — it was verifiably broken, so the WORKING dialog was promoted instead and the broken one deleted. AssignPackageDialog moved out of player-packages-list.tsx into its own module, imported by both Player Packages and Members. Evidence against the orphan, all read from the live code: (1) it consumed `data.players` from /api/admin/players/search, which returns `{ results }` at route line 102 — the student list was always empty, so no assignment could ever begin; (2) it read `pkg.price` and `pkg.type` from /api/admin/lesson-packages, which does `select("*")` on lesson_packages — a table whose generated Row type has price_amount and package_type and NO price or type — so `pkg.price.toFixed(2)` threw as soon as the package list rendered; (3) it fetched /api/admin/players/<email>/balance, which does not exist anywhere in app/api (the real route is /api/admin/wallet/customer-balance, keyed by user_id, returning balance_amount). It also hardcoded '$', violating DEC-108. git grep confirmed it had never been rendered by any component, which is why none of this had ever surfaced.
Alternatives considered
'Reuse, don't reinvent' points at the implementation that actually works, not the one that merely looks purpose-built. Promoting it gives both screens one code path, one validation, one write path, and deletes 573 lines of broken code that a future agent would otherwise have mounted in good faith — exactly the trap hit here. The brief's premise was checked rather than assumed, which is the only reason this was caught before it shipped.
From /api/admin/facilities/my — the endpoint the sidebar's facility switcher already calls — matching on the current slug, with canAssign = role !== null && WRITE_ROLES.includes(role) and WRITE_ROLES mirroring player-packages/_lib/write-roles.ts (owner/admin/manager). The button is hidden, not disabled, while the role is unknown or insufficient. A failed probe fails CLOSED and is console.error'd with a comment saying why dropping it is safe.
Alternatives considered
The gate must answer at the same scope as the thing it gates. A screen-level action needs a screen-level role source, and facilities/my is an existing, already-consumed one — no new endpoint, no route contract changed. This is a DISPLAY decision only: POST /api/admin/player-packages re-checks the role on every write and remains the thing that keeps the data safe.
Remove TC-255, TC-258, TC-259 and TC-261 outright, each with a header note in its file recording what they covered and why they went. Keep TC-256/257 (student profile + Customer 360, which still exist), TC-261f (the /api/admin/students paging contract — the route is NOT dead, the groups panel calls it for unassigned students) and TC-261g (Setup → Player Packages, never part of this screen). TC-262 simply stops clicking a tab that no longer exists.
Alternatives considered
Deleting a screen should delete its tests, not leave them skipped — but only after checking, per test, whether what it covered survives somewhere else. That check is what kept TC-261f and TC-256/257 alive and what stopped the route deletion.
(a) ported the identical fixed sibling-sync logic into a new apps/pos/src/lib/split-discount-sync.ts (not shared via packages/common or a cross-app import — apps/pos and apps/terminal each define their own local CartLineItem type; forcing one shared module across that boundary for a six-call-site helper wasn't worth the added generic/import-path complexity this close to shipping, same trade-off as DEC-355/362 already made within a single app). (b) ported payableItems() into apps/pos/src/lib/line-item-pricing.ts and wired it into pendingTotal/pendingItemCount; also added the isBooking/isPrePaid fields (optional) to apps/pos's own CartLineItem interface, since apps/pos never WRITES a booking cart line itself (no check-in flow there) but its ticket-modal.tsx DOES read cart_json rows that apps/terminal wrote, and the interface previously didn't even declare these fields even though the runtime data can carry them. (c) added .eq('orders.status', 'completed') to usePaymentMethodsReport's query, matching useFullReport. (d) hardened computeTicketDiscountCents() with the explicit isNaN/negative guard the two inline copies already had (parseFloat('') is caught by discount.input || '0', but parseFloat of a non-numeric string like 'abc' is NaN and wasn't previously guarded, which could have made the shared function return NaN where the inline versions correctly returned 0) and routed both checkout-dialog.tsx and cart-sidebar.tsx through it, closing the last two copies of this formula.
Alternatives considered
Setpoint's own standing rule: a bug fixed in one app must be checked against the other two before considering the fix done. (a)-(c) are exactly that check catching real, un-ported fixes; (d) is finishing a consolidation this same branch already started elsewhere instead of leaving two of four copies behind.
Documented as a deliberate, tracked limitation — not redesigned this round. An inline code comment already existed at the query site; this adds the proper DEC entry so the split is tracked as a known, decided trade-off rather than something a future engineer rediscovers as a mystery. The split is real but narrow: it only matters for orders/payments within a few hours of either edge of a report's date range whose order-creation and payment-capture moments straddle midnight — for the vast majority of orders (payment captured in the same transaction as the order, per create_pos_order_atomic) the two bases agree exactly.
Alternatives considered
The review's own instruction was explicit: don't attempt a full redesign right now, but make sure the limitation is tracked properly rather than just inline-commented. This is a real, narrow-edge-case limitation worth a permanent decision record, not a full engineering effort this round.
Checked both real write paths directly: order-detail-modal.tsx's useIssueRefund and the academy-reversal RPC (20260902172043_academy_payment_pos_reversal.sql) BOTH insert a pos_refunds row with status: 'completed' directly, atomically, in the same statement that flips pos_payments.status to 'refunded' — there is no intermediate 'pending' phase anywhere in this codebase's actual refund flow; 'pending' is the column's DEFAULT but no code path ever creates or leaves a row in that state. The schema shape (nullable processed_at alongside the pending default) implies an original intent of 'pending' meaning 'requested but not yet confirmed by a gateway — money hasn't left yet,' which the actual synchronous, always-already-completed write paths never needed. Tightened the SQL side's filter (v_returns and refund_totals) from NOT IN ('failed','cancelled') to = 'completed', matching apps/pos's existing (and, per this investigation, semantically correct) filter — the SQL side was the one that was wrong, not the TS side; no change was needed on the TS side.
Alternatives considered
The review's own instruction was explicit: investigate ground truth, don't just make the two sides match arbitrarily. Reading the actual INSERT statements settled it definitively rather than guessing from the schema's DEFAULT value alone.
Widened the trigger's condition from OLD.status = 'payment_requested' to OLD.status IN ('payment_requested', 'closed'). Verified directly (not assumed) that the only path that ever sets status: 'closed' — useCloseTicket, in both apps — is invoked exclusively as checkout-dialog.tsx's post-order-creation step (both the regular and split-payment paths, confirmed by reading the call sites), and never touches cart_json itself. No 'reopen a closed ticket' mutation exists in either app (confirmed by grep), so unlike 'payment_requested' (which has two legitimate exit transitions: ticket-close-on-settlement and staff cancellation) there's no third legitimate transition to account for on the 'closed' side — 'closed' is a terminal state in this codebase's current design, making it a safe, sufficient proxy for 'this ticket already produced an order' without needing a live EXISTS (SELECT ... FROM pos_orders) check inside the trigger.
Alternatives considered
Same reasoning as DEC-360, extended to the other terminal state this table has — a closed ticket and a completed order are the same 'already produced a financial record' state, one level apart, and deserve the identical structural guarantee.
(a)+(e) fixed together: extracted the sibling-sync logic into a new apps/terminal/src/lib/split-discount-sync.ts (syncSplitDiscountToSiblings), used by both useSaveTicketCart and generate-payment-link/route.ts, and added .eq('status','open') to its per-sibling UPDATE (not just the initial SELECT) plus a thrown error check on that write — closing the SAME two gaps (write-time race, no error check) in BOTH call sites at once, since useSaveTicketCart's original copy had them too and was never separately flagged. (b) added .eq('status','open') + .select('id').single() to generate-payment-link/route.ts's own ticket update — throws instead of silently no-op-ing if the ticket wasn't actually 'open' when the update ran. (c) isSourceIdConflict now matches the exact, double-quoted constraint name (/"idx_pos_orders_source_id"/) instead of the bare substring, in both the cron and the webhook. (d) restored a cheap early idempotency check in pos-stripe-webhook/index.ts: look up an existing order by source_id FIRST, before touching cart_json at all, and return success immediately if found (also closes the ticket defensively on that path, matching the pre-round-8 behavior).
Alternatives considered
None of these five individually changes behavior for the common, non-racing, non-error case — they close narrow windows (a concurrent status change, a duplicate request, a mis-scoped fallback, a redelivery timing edge case) that either silently corrupt state or turn a should-be-free no-op into a hard failure.
Superseded DEC-341's order-date basis with EVENT-date basis for v_returns (r.created_at), payment_methods' scoped_payments (p.created_at), and fees (p.created_at) — a financial EVENT (a refund, a payment capture) is now attributed to the day it actually happened, not to an ancestor record's (the order's) creation date, matching how a bookkeeper reconciling cash movements for a specific day would want to see it: today's deposits minus today's refunds, regardless of which day's sale a refund traces back to. gross_sales/discounts/net_sales's own numerator/total_collected/avg_order_cents stay on ORDER-date basis deliberately — those are intrinsic properties of the order record itself (subtotal/discount/total set atomically at order creation), not separate events that can happen on a different day, so they carry none of the permanent-invisibility risk that motivated this change for refunds and payments. apps/pos/src/queries/use-reports.ts's useFullReport switched its payments fetch to scope by the payment's own created_at (matching usePaymentMethodsReport's pre-existing convention) instead of deriving payments transitively via order ids already filtered by order date — this also required decoupling the payments fetch from the orders-pagination step entirely (a payment can now belong to an order outside [dateFrom, dateTo]) and adding its own pagination loop, since it's no longer bounded by the same order-id list.
Alternatives considered
DEC-341 wasn't wrong for the context it was made in (matching a live dashboard's own convention was reasonable at the time) — it just didn't fully work through what changes when the SAME logic backs a one-shot, never-regenerated artifact instead. Re-deciding this on that basis, once actually worked through, is exactly the kind of thing CLAUDE.md's self-debate requirement exists for.
Verified directly, not assumed: NOT every write path shares one function — cart-sidebar.tsx's Save/Charge/Send-to-Kitchen route through useSaveTicketCart, but split-move-modal.tsx's own move/split flows call separate mutations (saveSourceCart, splitItem, splitBill) that write facility_tickets.cart_json independently, and split-item-detail-modal.tsx (DEC-354) writes directly via its own supabase calls. An app-level check in any one shared function would leave the others exposed — confirming the DB is the only place this can be enforced for every current AND future caller. Added facility_tickets_prevent_locked_cart_mutation (20260907071623), a BEFORE UPDATE trigger mirroring pos_orders_prevent_completed_mutation's exact pattern: rejects any cart_json change when OLD.status = 'payment_requested', with no exception needed — the two legitimate transitions OUT of that state (ticket-close on settlement, and useCancelPaymentRequest's revert-to-open) both verified (by reading their actual update payloads) to never touch cart_json themselves, only status/closed_at/payment_link_url/payment_link_id. discount_json (the table's only other jsonb column) confirmed dead — referenced only in generated types, never read or written by any app code — so cart_json is the table's one customer-facing, total-affecting column and the only one that needs locking.
Alternatives considered
"Make it structurally impossible, not caller-dependent" — this closes the bug class for every current caller (verified, not assumed) and every future one, the same way pos_orders_prevent_completed_mutation already does for completed orders.
Both of the two options raised in review, not just one: (1) a new backfill migration (20260907041748_backfill_pos_orders_idempotency_key.sql) sets idempotency_key = 'payment_link:' || source_id for every existing pos_orders row where source = 'stripe_payment_link' AND idempotency_key IS NULL — safe to run more than once (only ever touches still-NULL rows), and gives every legacy row the EXACT key the new code will compute for it, so the very first post-deploy redelivery hits the RPC's own ON CONFLICT path directly. (2) Belt-and-suspenders: both the webhook and the cron also catch a unique_violation (code 23505) specifically mentioning idx_pos_orders_source_id/source_id, fall back to looking up the existing order by source_id, self-heal by backfilling that row's idempotency_key on the spot, and return it as an idempotent replay — covering the case where the code deploy somehow races ahead of the migration (this repo's own deploy runbook already sequences 'run migration' before 'deploy functions', but a belt-and-suspenders client-side guard costs little and removes the dependency on getting that order exactly right).
Alternatives considered
This is exactly the kind of thing that bites during a deploy window if not planned for, per the review's own framing — a single-layer fix leaves either a real recovery gap (backfill-only) or a slower permanent fallback path (app-only). The combination costs one small migration and ~20 lines per call site and closes the window from both the schema side and the runtime side.
Investigated, did not merge — instead aligned v_returns's WHERE clause to add the SAME p.status IN ('captured','settled','refunded') filter payment_methods' scoped_payments already applies, so the two blocks can no longer silently disagree about what counts as a refund-eligible payment (the actual correctness gap), while leaving them as two separate queries (the performance question). pos_refunds is small and rarely written relative to pos_payments/pos_orders — refunds are a small fraction of orders in any real POS — so this query's real cost is bounded and cheap, unlike the O(payments) correlated-subquery pattern (a per-row LATERAL) that caused the RPC's actual historical 8s timeout. A true merge is achievable (one shared WITH clause populating v_returns via a scalar subquery and v_payment_methods via a second subquery in the same top-level SELECT) but requires restructuring this function's statement order — v_returns is needed before the financial jsonb block runs, ahead of where payment_methods currently computes its own CTEs — real, nontrivial surgery on a production financial-reporting RPC for a performance gain very unlikely to matter at pos_refunds' realistic volume.
Alternatives considered
The review's own fallback instruction was explicit: if a clean merge isn't achievable without another timeout-risk trade-off, document why rather than leave it unaddressed. Investigated the actual query shapes and table-size reasoning rather than assuming either 'obviously safe to merge' or 'obviously fine to ignore' — the real, closeable gap (filter consistency) is now closed; the performance question is documented, not silently dropped.
avg_order_cents now subtracts v_returns in its own numerator too, matching apps/pos's useFullReport.avg_order convention (net_sales / order_count) exactly. Separately, apps/pos/src/queries/use-reports.ts's usePaymentMethodsReport and useFullReport both now admit 'refunded' alongside 'captured'/'settled' on pos_payments and net each payment's own refund(s) (via pos_refunds.payment_id, chunked the same way this file already chunks other large id lists) before rolling up by method — the identical DEC-344 fix, applied to the in-app reports it was never ported to.
Alternatives considered
Two operator-facing reports silently disagreeing on the same numbers is exactly the failure mode this entire branch exists to close — shipping the netting fix in the email while leaving the in-app pages on the old math would have reopened that exact class of bug from a different door.
Extracted buildOrderRpcLineItems/buildOrderRpcItemDiscounts/buildOrderRpcStockTargets into a new apps/terminal/src/lib/order-rpc-payload.ts, used by both use-order-mutations.ts (till checkout) and check-payment-links/route.ts (cron). Order-level discount rows (till-checkout's cashier/resolver split vs. the payment-link paths' single ticket discount) stay inline at each call site — different enough semantics that forcing them through one shared builder would obscure real differences rather than remove duplication. The catalog-items stock-target FETCH also stays at each call site (a trivial .from().select() call against a differently-typed client per caller); only the pure transformation from fetched rows to the RPC's target/delta shape moved into the shared module.
Alternatives considered
Same rationale as DEC-352: the write-path math and the payload shape that determines what actually lands in pos_order_items/pos_order_discounts should have exactly one implementation everywhere a technical barrier doesn't force a second one.
Fixed both call sites in ticket-modal.tsx (handleSave AND handleRequestPayment) via two new local helpers (getLocalSplitDiscountSource/buildLocalCartJsonWithSplitDiscount) that mirror cart-sidebar.tsx's getSplitDiscountSource/buildCartJsonWithSplitDiscount exactly, now passing sourceTicketNumber/splitDiscountSource through both saveTicketCart.mutateAsync (handleSave) and the new requestPaymentLink.mutateAsync fields (handleRequestPayment). generate-payment-link/route.ts (a server route using the admin client, not the browser client useSaveTicketCart runs on) got its own copy of the sibling-sync loop rather than importing the hook — same 'server route can't call a React Query hook' constraint that already applies to Deno vs. app-local modules elsewhere in this branch.
Alternatives considered
"Make the reports accurate, do whatever needed" — this is the same category-vs-total accuracy problem in a third guise: a stale discountFromSource on a sibling ticket means THAT ticket's own eventual order (whenever it's charged) records the wrong discount amount, corrupting the exact reports this whole branch exists to fix.
Added isTicketLocked(status) (true for anything other than 'open') as a guard applied in two layers: (1) an authoritative, fresh re-check immediately before every write — persistOwnTicket now re-selects status alongside cart_json and throws if locked; every sibling-write loop (removeSplitFromSiblings, both handleQty branches, handleRevert) now selects status alongside cart_json and skips (not writes to) any locked sibling, collecting skipped tickets into a toast summary rather than silently dropping the sync; (2) a UI-hint layer — an ownTicketLocked state fetched once when the modal opens disables the qty stepper/revert button up front and shows a lock badge, plus a per-sibling 'Payment sent — locked' badge in the right panel, so staff see WHY a sync didn't happen instead of it looking like nothing was wrong. Locked siblings still count toward newParts/newShareCents in the redistribution math (their own share isn't recomputed, but the remaining split's total-parts denominator is unchanged) — a known, pre-existing approximation of this redistribution math, not something this guard introduces or was asked to redesign.
Alternatives considered
The write-time guard is the actual fix (prevents corrupting a Stripe-locked order's eventual pos_order_items/subtotal); the UI-hint layer prevents the guard from feeling like a silent, confusing no-op to whoever's tapping the now-disabled buttons.
Redid the fix on top of create_pos_order_atomic rather than ship on a superseded pattern. Merged origin/main into this branch (git merge, not rebase — check-payment-links/route.ts had been independently rewritten on both sides, and replaying 9 commits against a file that diverged via a full rewrite would have been far more error-prone than resolving two merge conflicts once). check-payment-links/route.ts now extends main's existing create_pos_order_atomic call with p_line_items (nested modifiers, kitchen_sent_qty, payableItems-filtered prepaid-booking exclusion, 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) — closing the category gap the RPC-only call left open, while keeping the DEC-345/350 fresh-cart_json-fetch-before-use fix (now feeding the RPC payload instead of a separate insert). pos-stripe-webhook/index.ts — untouched by #1063 — was separately migrated to call the SAME RPC with the SAME payment_link:<id> idempotency key format the cron uses, so a webhook-fires-first or cron-fires-first race now converges on one order via the RPC's own idempotency short-circuit instead of two independent non-atomic insert sequences. This closes a duplicate-order vulnerability class in the webhook that predates this branch entirely — discovered while integrating with #1063, not caused by it, and not something #1063 itself introduced or was ever responsible for closing (it never touched this file).
Alternatives considered
"Don't ship on a pattern that was just proven to cause production incidents" is a senior-engineering call, not a scope-creep one — #1063's own migration and DEC-333 exist specifically because the hand-rolled, non-atomic, TOCTOU-vulnerable pattern this branch's pre-merge fix used is a confirmed, not theoretical, prod risk (56 extra rows across Bangkok AND Bali, dating back to May 2026). Redoing the fix on the new RPC cost a substantial merge and two file rewrites, but shipping a second, brand-new instance of the exact pattern #1063 just spent a full incident response fixing everywhere else would have been indefensible once discovered — and it would have been discovered, since the webhook and cron are the same two files #1063's own code review already flagged as needing migration.
Extracted payableItems/grossLineTotalCents/computeGrossSubtotal/computeTicketDiscountCents into line-item-pricing.ts as new exports, alongside the existing effectiveUnitPriceCents/effectiveLineTotalCents/lineItemDiscountCents/lineItemDiscountName. Both ticket-modal.tsx and the cron route now import all of it — ticket-modal.tsx's local getLineTotal (an exact duplicate of the module's own effectiveLineTotalCents) and its inline cart_json-parsing-plus-gross-formula in pendingTotal/pendingItemCount (which also re-parsed cart_json inline instead of using the already-imported readTicketCart) were deleted entirely, not left as dead code. The Deno webhook keeps its own hand-port (real constraint, not swept), but the TODO comments on both files now point at a single shared module instead of two independently-evolving Next.js copies.
Alternatives considered
DEC-343 and DEC-347 both already flagged this drift risk without fully closing it — this is the round where the write-path math and the UI math that determines the actual Stripe charge finally point at one shared implementation for everything that CAN be shared in this app, with the one remaining hand-port (the Deno webhook) reduced to the smallest possible surface and clearly marked as a real, not organizational, constraint.
Treat "Send payment link" as an implicit save, at the source: generate-payment-link/route.ts now accepts an optional cartJson in its request body and includes it in the SAME facility_tickets update call that already sets status/payment_link_url/payment_link_id — the identical plain `cart_json` write use-ticket-mutations.ts's useSaveTicketCart already does for an existing ticket, not a new persistence path or a second write. ticket-modal.tsx's handleRequestPayment now sends buildTicketCartJson(localCart, localDiscount) (the same call handleSave uses) alongside the amount. Guarded with Array.isArray(cartJson) so an old/defensive caller that omits it leaves the ticket's last-saved cart_json untouched rather than risk overwriting it with null.
Alternatives considered
"Fix at the source" per Alba/coordinator's direction: the discrepancy exists because one code path (Stripe charge) reads live state while a sibling path (report generation, via cart_json) reads persisted state, with no write connecting the two at the moment they must agree. Persisting cart_json in the SAME request that fixes the charge amount is the one point in the flow where both values are already known and about to diverge if not saved together.
Both fixed by importing/porting the actual shared formulas, not re-deriving them a third time. (1) Item-level discounts: apps/terminal/src/lib/line-item-pricing.ts's effectiveUnitPriceCents/lineItemDiscountCents/lineItemDiscountName — the same functions use-order-mutations.ts itself imports — are now imported directly in the cron (same app) and hand-ported in the Deno webhook (same duplication pattern as everything else there). pos_order_items.total_cents stays GROSS per line (till-checkout's own convention — item discounts are NOT baked into it), and a new buildDiscountInserts() writes one pos_order_discounts row per discounted item (order_item_id set) plus one order-level row for the ticket-level discount, mirroring use-order-mutations.ts's cashierPortion/resolverPortion + per-item forEach exactly. discount_total_cents is now itemDiscountTotalCents + ticketDiscountCents (till's own totalDiscountCents formula), and the ticket-level discount's own percent base was corrected to itemDiscountedSubtotal (grossSubtotal minus item discounts) rather than raw grossSubtotal — ticket-modal.tsx's localSubtotal (the actual percent base Stripe's charge is computed from) is itself already item-discount-net via getLineTotal(). (2) Prepaid bookings: a new payableItems() filter (`!(li.isBooking && li.isPrePaid)`), applied to the parsed cart items BEFORE computeGrossSubtotal, buildOrderItemInserts, item-discount summation, and stock decrement, mirroring ticket-modal.tsx's `.filter((i) => !(i.isBooking && i.isPrePaid))` verbatim.
Alternatives considered
Both bugs are the identical failure mode DEC-347 already named: this write path re-deriving a subset of ticket-modal.tsx's math instead of mirroring the whole picture. Fixing them by importing/porting the actual canonical functions (rather than writing a fourth independent implementation) is the only way to guarantee no future drift — a change to line-item-pricing.ts's discount math or ticket-modal.tsx's prepaid handling now has exactly one place (each) left to also update in the webhook, and zero to update in the cron (which imports directly).
Rewritten as two CTEs computed once each, not per-row: scoped_payments filters pos_payments/pos_orders to the report window exactly as before, and refund_totals pre-aggregates pos_refunds by payment_id (joined to scoped_payments so it only scans refunds against payments actually in this window, not the whole table) — then the final SELECT does one hash join between the two pre-aggregated sets. Same O(1)-aggregate-plus-join shape every other block in this function already uses (categories, top_items, payment_methods' own gross sum), instead of the one LATERAL that was the odd one out.
Alternatives considered
Correctness fixes that make a report accurate are wasted if the query then times out and the report never sends at all — this RPC's own migration comments already document that exact failure mode once. A performance regression introduced by this same PR's own correctness fix (DEC-344) would be a self-inflicted version of the bug this function has already been fixed for once before.
Checked, don't guess: read order-detail-modal.tsx's RefundModal in full. Its item-selection refund path (the common one — 'Select items to refund') computes refundAmount from pos_order_items.total_cents (pre-tax) net of item/order-level discounts via effectiveItemPrice/itemTotal, with order_taxes read ONLY for the order-detail display elsewhere in the same file — never folded into the refund calculation. So a typical item-selection refund is actually PRE-tax, not tax-inclusive, and the 'custom amount' fallback is free-text with no enforced basis either way. pos_refunds has no column recording which basis a given row used. Given the data itself doesn't reliably carry a tax-inclusive/exclusive distinction, there is no honest way to carve out a tax-exclusive-only figure for net_sales without guessing per refund row — left as a documented, accepted approximation (SQL comment at the financial jsonb_build_object) rather than inventing an unsupported split.
Alternatives considered
The investigation surfaced something more important than the reporting question it was asked to answer: the item-selection refund path appears to under-refund the tax portion in real card/gateway refunds today (refundAmount, which is also the literal amount sent to the refund mutation, never adds tax back in), a correctness issue in the refund flow itself, independent of and prior to this report-accuracy branch. Flagged explicitly to Alba as a separate, out-of-scope finding rather than silently noticed and left for someone else to rediscover from scratch.
Mirrored till-checkout's approach exactly, not invented: (1) parse cart_json via a readTicketCart port (Deno webhook) / the actual imported readTicketCart (Next.js cron, same app) instead of a raw array cast — this strips the discount_meta pseudo-item out of `items` and returns the discount separately, fixing the insert-crash risk as a side effect. (2) Compute grossSubtotal from the SAME per-line formula buildOrderItemInserts uses (guaranteeing Sum(pos_order_items.total_cents) === pos_orders.subtotal_cents, exactly the invariant till-checkout's own subtotalCents/lineItemInserts pair relies on) and set pos_orders.subtotal_cents to that GROSS figure instead of Stripe's post-discount amountTotal. (3) Compute discountCents with ticket-modal.tsx's own percent/fixed formula against grossSubtotal, clamped to [0, grossSubtotal], and record it as pos_orders.discount_total_cents — total_cents stays amountTotal (the real amount Stripe charged), matching till-checkout's convention that line items stay GROSS/undiscounted while the order carries the discount separately. (4) Also insert one pos_order_discounts row (order_item_id null, type 'fixed') when discountCents > 0, mirroring use-order-mutations.ts's cashierPortion/resolverPortion rows, so the ledger this report doesn't even read stays consistent with every other order type for order-detail display and refund math.
Alternatives considered
Two real bugs bundled together — a totals mismatch (measurable, silent) and an insert-crash risk (would have surfaced as a hard failure the first time a discounted payment link was paid) — both traced to the same root cause: cart_json's discount encoding was never accounted for on this write path. Fixing both by mirroring the till-checkout convention exactly, rather than a bespoke scheme for payment-link orders, keeps exactly one discount-recording convention in the codebase instead of two that could drift.
No clean line-level attribution exists — left as a documented gap, not fixed. pos_refunds has no order_item_id and no line-breakdown column at all: it FKs to exactly one pos_payments row and stores a single amount_cents lump sum. Confirmed further against order-detail-modal.tsx's RefundModal: staff CAN select specific items in the UI, but that selection only computes a suggested lump `amountCents` client-side — the selection itself is never sent to the server or persisted anywhere. There is no data path from a pos_refunds row back to which item(s) it covered. Documented with an explicit SQL comment at both the categories and top_items CTEs in the migration, referencing this entry, so it reads as a deliberate, known gap rather than an oversight.
Alternatives considered
Same principle as DEC-344: an explicitly documented gap that a future PR can close with real schema work is honest; a silently wrong number in the meantime is not. Categories/top_items already disagreed with total_collected before any of this PR's returns work — this PR narrows but does not close that gap, and says so in the SQL itself.
Traced the full path rather than assuming either way: send_ticket_items_to_kitchen never touches pos_order_items — it writes print_jobs directly against the open ticket and records progress back into facility_tickets.cart_json's own kitchenSentQty field (same field this PR's buildOrderItemInserts already reads via `li.kitchenSentQty ?? 0`, mirroring use-order-mutations.ts). No pos_orders/pos_order_items row exists for a payment-link ticket until settlement, so this PR's insert is the ONLY set of pos_order_items rows ever created for it, not a second one. Because kitchenSentQty carries through into the new row's kitchen_sent_qty column, an item already fully sent pre-payment correctly triggers no reprint (kitchen_sent_qty = quantity), while an item never sent before payment finally reaches the kitchen for the first time. Confirmed as a pre-existing gap this PR incidentally closes (kitchen was never notified for unsent payment-link food items before), not a new duplicate-ticket bug — no code change made beyond the explanatory comment left at both kitchen_sent_qty call sites (webhook + cron) so this trace doesn't need re-doing later.
Alternatives considered
The review asked for a trace, not a guess, precisely because the two possible answers (newly-introduced duplicate vs. incidentally-fixed pre-existing gap) called for opposite responses — a silent assumption in either direction risked either shipping a real duplicate-ticket bug or deleting a genuine, if accidental, improvement. Reading send_ticket_items_to_kitchen's actual body (not just its migration's comment) was the only way to be certain which table it writes to.
Checked the actual status space before changing anything: apps/terminal's transactions page FILTER_STATUSES enumerates the canonical set as captured / settled / refunded / failed (no separate 'voided' or 'cancelled' status exists for pos_payments — those belong to other tables like bookings.payment_status and pos_refunds.status). Added 'refunded' to the filter (now `IN ('captured', 'settled', 'refunded')`) so a refunded payment's row survives into the aggregate and the existing LATERAL can net its refunded_cents against amount_cents as originally intended. Left 'failed' excluded — a failed payment never actually captured money, so including it would count revenue that was never collected.
Alternatives considered
DEC-342's decision (attribute refunds via payment_id, order-date scoped) was correct — this was a pure implementation bug in the surrounding WHERE clause, not a wrong decision to revisit. Logged separately rather than editing DEC-342's entry in place, matching this file's append-only convention for corrections.
Left triplicated, not extracted. supabase/functions/pos-stripe-webhook/index.ts keeps its own hand-written copies (plus a duplicated UUID_RE and a duplicated stock-decrement port of packages/common/src/stock.ts's isTrackedStockCartLine/resolveSaleStockTargets), each commented '// TODO(follow-up)' pointing at its sibling copy in apps/terminal/src/app/api/cron/check-payment-links/route.ts.
Alternatives considered
The stock-decrement half of this duplication (isTrackedStockCartLine/resolveSaleStockTargets, not just UUID_RE) makes the trade-off sharper than the existing precedents: it's the first time an edge function needs to mirror BRANCHING logic, not just a pure helper or a small constant, so a future change to the shared stock-targeting rules in packages/common/src/stock.ts must be manually ported into pos-stripe-webhook/index.ts or the two runtimes drift. Flagged explicitly via the TODO comments (and this entry) rather than accepted silently, so a future pass — ideally one that also decides whether Deno-edge-functions-importing-packages/common is worth solving generally, not just for this one file — can revisit it deliberately instead of rediscovering the drift risk from scratch.
Yes — checked the schema first rather than guessing. pos_refunds.payment_id is a required FK straight to one pos_payments row (not to an order or a generic ledger entry), and pos_payments.method is a single text column per row — confirmed against order-detail-modal.tsx's refund insert (`.from("pos_refunds").insert({ payment_id: paymentId, ... })`), which always names one specific payment. A split-tender order already creates one pos_payments row per tender, so a refund against one tender only ever points at that tender's own payment_id/method — there's no split-tender ambiguity to resolve. Fixed properly: payment_methods now LEFT JOIN LATERALs each payment to the sum of its own non-failed/non-cancelled refunds and subtracts that before aggregating by method, using the same order-created_at window and refund-status filter as v_returns (DEC-341), so the two sections net out consistently. (Note: the outer query's own status filter turned out to also need a fix for this LATERAL to actually take effect — see DEC-344.)
Alternatives considered
The point of this entire fix (category totals vs. Total Collected, now returns vs. Total Collected) is that two sections of one report must not silently disagree. Once the schema confirmed refund-to-method attribution is exact rather than approximate, gross-only would have been a second version of the same original bug, just in a different corner of the same RPC — not a defensible trade-off, a missed fix.
Order-date basis. v_returns now joins pos_refunds → pos_payments → pos_orders and filters on the ORDER's created_at against the report window (ro.created_at), not the refund's own created_at. Checked for precedent first: apps/pos/src/queries/use-reports.ts's Sales Summary already scopes refunds this way — it fetches refunds by payment_id off a payments list that was already filtered down to orders in the report's date range, never filtering by the refund row's own timestamp. Matches that existing convention rather than introducing a second, different one.
Alternatives considered
A report that can show a slow period going negative because of a refund on a sale from a completely different period is a correctness bug, not a rounding quirk — clubs read Total Collected as 'this period's business,' and money from a different period showing up as a negative adjustment breaks that reading. Matching apps/pos's existing basis also means the two operator-facing reports (email vs. in-app dashboard) now agree with each other on how refunds attribute to a period, not just internally consistent each on its own.
Five rulings, all in service of one idea: every cell in this table is a claim about money and must be true or absent, never approximately right. (1) DUE IS THE DEFAULT VIEW. The status dropdown becomes a Due | Paid | All segment, Due selected on open, persisted in the URL (?view=). Chasing money is the daily job; reconciling what already came in is the occasional one. (2) MARK PAID IS ONE TAP, on the row, not buried in the overflow menu — a popover taking tender, amount and an optional note. The overflow keeps the slower actions. (3) 'pending' IS NOT A PAYMENT METHOD. It is the ABSENCE of a recorded tender: DEC-321 maps a payment_link package sale to it, and create_lesson_atomic defaults a roster seat to it. It now renders as an em dash. One METHOD_LABELS map is the vocabulary for the column, the filter and the settle popover, and one server-side settlementMethod() helper is the single place the value is interpreted. A paid payment_request reports the tender from the payments row that settled it rather than its checkout provider, and an unpaid one reports nothing at all. (4) CADENCE IS THE TEMPLATE'S TO DECLARE. lesson_packages.billing_period is what says money recurs and how often; player_packages.next_billing_date is only a date. Without a period the row is One-off and carries no next date. (5) REVERT TO DUE IS APPEND-ONLY. A new payments row with the negative amount, status 'succeeded', pointing at the original via payments.reverses_payment_id, with an operator reason that is mandatory. The original row is never updated and never deleted.
Alternatives considered
The unifying rule is that an absent value beats a plausible one. Every defect fixed here was the view answering a question it did not actually know the answer to — 'Pending' where no tender was captured, a provider name where no money had moved, a next billing date on something that does not recur, a UTC day boundary standing in for a Bangkok one. None of them looked broken; each was a well-formed wrong answer, which is why they survived to a live view. The reversal shape was agreed with the pos-bridge agent BEFORE either side built, which is why one settlement equals one payments row equals one POS order, and one reversal equals one pos_refunds row, with no reconciliation logic anywhere. Verified end to end against the local stack: a counter settle wrote one payments row and one mirrored POS order; reverting it wrote the negative row, reopened the package, produced a pos_refunds row, and moved the Collected tile from ฿32,500 to ฿25,000 — exactly the reversed amount — with no change to the tile's own code. Two of these went deeper than the display. 'pending' being treated as a tender was cosmetic; a package with no billing_period was not — dispatch_package_billing selected it and process-package-billing substituted 'monthly', so a one-off ten-pack in the seed was due to be re-invoiced every month on a guess. Fixed at the dispatcher, the processor and the feed rather than in the cell that happened to show it.
billing_mode DEFAULT 'manual', and the migration explicitly stamps every pre-existing lesson_packages row 'manual'. Automatic billing is opt-in per package, chosen by an operator in the package form. The only packages that keep billing themselves on deploy are the ones already carrying a Stripe subscription, which continue down the untouched invoice.paid path.
Alternatives considered
Production discipline: additive, reversible, and behaviour-preserving on deploy. The feature's value comes from operators turning automatic ON deliberately, package by package, not from a migration deciding it for them.
pg_cron dispatcher -> net.http_post -> Supabase edge function, with the internal secret read from Vault, copied verbatim from dispatch_daily_sales_reports. Two hourly jobs: dispatch-package-billing and dispatch-payment-reminders. Each dispatcher selects only the rows due in the facility's local hour and fires one HTTP call per unit of work with a 10s timeout.
Alternatives considered
Built-in first, and consistency with the three dispatchers already in production. It also keeps scheduling next to the data, so a reminder cannot be scheduled for a request that no longer exists.
Billing cycles become a Setpoint-side scheduler with three lanes. Automatic-with-card stays exactly as it is (Stripe subscription, invoice.paid -> reset_package_sessions, no regression). Automatic-without-card rolls the cycle on next_billing_date, creates a payment_request for the period and schedules its reminders, and grants the new period's sessions ONLY when that request is marked paid. Manual never bills on its own: the package surfaces under 'Renewals due' in the Payments view and an operator's click creates the request. billing_period is widened additively to weekly/monthly/quarterly/annual, and rollover is computed in the facility's timezone with month-end anchor clamping.
Alternatives considered
The sessions-on-payment rule is the load-bearing business decision: it makes the automatic-without-card lane safe to run unattended. Everything else follows from the fact that Setpoint, not Stripe, must own the cycle when Stripe is not in the picture.
A message_templates table keyed by (facility_id, key, channel), where facility_id NULL is the seeded system default and a facility row overrides it. Resolution is facility-then-default, so a club that never opens the editor still sends correct copy. applyMergeTags is extracted from the CRM campaign send route into packages/common so exactly one merge-tag implementation exists, and merge-tag VALUES are HTML-escaped on the email path.
Alternatives considered
The escaping rule is the security-relevant half of this decision: operator-authored template bodies interpolate customer-supplied values into HTML that gets emailed, so unescaped values are a stored-injection path into another person's inbox. Centralising the parser is what makes that guarantee enforceable in one place.
Ship the manual lane now and stub the automated one, both behind a MessagingProvider interface that mirrors the existing payments provider abstraction. The manual lane renders the template server-side and returns a wa.me deep link carrying the message and the payment link; the operator clicks and WhatsApp opens pre-filled, and the reminder is recorded with status 'sent_manual'. The whatsapp_cloud provider throws a clear not-configured error until credentials exist. Enabling automated sending later is a facility setting plus one provider file.
Alternatives considered
The manual lane needs no vendor, no approval and no recurring cost, and it delivers the operator-visible outcome (a reminder reaches the customer on WhatsApp with a working payment link) today. The interface is what keeps this from being throwaway work. Open with Nestor: whether to stand up the Cloud API, and which number it would send from.
A first-class payment_requests table (facility, user, subject_type/subject_id, amount, currency, status, provider, checkout_id/url/expiry, due_date, created_by, paid_at). The hosted link is generated lazily through the existing resolvePaymentProvider(facility_id) abstraction at send time, and regenerated if it has expired, so the operator never pastes a link by hand. Provider webhooks settle the request by checkout_id, idempotently.
Alternatives considered
The request must exist before any provider object does, because the most common case today is a club with no online payments at all. That inversion — local subject first, provider artefact second and optional — is what lets reminders, due dates, overdue ageing and manual cash collection all work uniformly.
Yes, and the root cause gets fixed rather than papered over: payments.type is widened additively with 'package', and the assignment route writes a payments row for in_club, payment_link and credits sales with the method mapped honestly (cash / pending / credits). Complimentary and zero-price sales write no payments row, because no money moved.
Alternatives considered
Architecture-first over a UI patch, and it is the difference between a Payments view that reflects the club's actual takings and one that shows only the handful of Stripe transactions. The CHECK widening is additive and safe on live data.
Inside the existing plpgsql RPCs (consume_package_session, refund_package_session, reset_package_sessions) and a new adjust_package_sessions RPC, in the same transaction as the counter mutation. The one exception is purchase_grant: session buckets are created by plain INSERTs from at least three places (the POST route, assign_package_with_credits, the Stripe webhook), so those rows are written by an AFTER INSERT trigger on player_package_sessions. The rejected trigger alternative below is an AFTER UPDATE trigger on counter changes; an INSERT trigger carries no ambiguity about cause, since a newly created bucket can only be a grant. A failure to write the ledger row aborts the balance change. The admin PATCH route stops writing player_package_sessions directly and calls the RPC, so the reason the UI already collects is finally persisted along with actor_id.
Alternatives considered
Atomicity is the requirement — a balance that changed without a ledger row is a corrupted audit trail, and log-and-continue is banned by the engineering rules. Keeping the write next to the mutation also means create_lesson_atomic gets it for free, since it already calls consume_package_session.
A new append-only package_session_transactions table: one row per balance change, carrying delta, balance_after, reason (purchase_grant / consume / refund / admin_adjust / backfill_balance / renewal_reset / expiry_forfeit / cancellation_forfeit), lesson_id, class_id, actor_id, note and source (live / backfill). No UPDATE or DELETE path exists in application code. Existing packages are backfilled from surviving lesson links with source='backfill', and the ledger's running sum must reconcile to sessions_remaining for every package.
Alternatives considered
Three independent code paths actively destroy this history today, so nothing short of dedicated append-only rows can answer 'where did my sessions go'. The closest structural precedent in the codebase is crm_discount_applications, the discount ledger, which is exactly this shape: immutable rows, an idempotency key and an application-level read API.
Yes, as a stacked follow-up on PR #1028. group_report_subscriptions rows (name, facility_ids[], one recipient each, frequencies, send hour anchored to an explicit IANA timezone since the group spans Bangkok/Bali) + dispatch_group_reports() hourly cron + send-group-report edge function + an owner-only card in Settings → Reports. The email is fixed-format and compact by design (Pablo's call — no per-recipient section toggles): group hero with totals PER CURRENCY (THB and IDR are never converted or summed together — honesty over a fake single number), the club comparison table (collected/occupancy/played/no-show/new customers per club with deltas), a 5-line mini-scorecard per club (vs previous period AND vs last-week-same-day for daily / vs last year otherwise), and the group's on-the-books outlook. Computation is pure reuse: facility_business_report once per club over the SAME calendar period — zero new analytics SQL. Access is the strictest gate yet: create/see/edit requires the OWNER role at EVERY club in the group, enforced three times (RLS policy over unnest(facility_ids), requireOwnerOfAll in every API route — including the union of old+new club sets on edit, so an owner of A+B can't re-point a subscription at clubs they don't own — and the UI card self-hides for anyone owning <2 clubs).
Alternatives considered
The entire feature is composition: the per-club RPC already returns everything per facility, so the group report is an orchestration + template concern (one RPC call per club + a compact HTML). Verified live against a seeded second club in IDR: dual-currency hero, side-by-side table and per-club minis render with real deltas (tc-280 asserts the rendered IDR amount inside the preview iframe; tc-280b asserts anonymous 401s).
Access: a two-tier explicit allowlist (report_access table). The facility OWNER always has full access and is the only one who can edit the list; allowlisted emails get 'view' (settings + previews) or 'manage' (also recipients). RLS floor is owner-only on both report tables; every API re-checks via requireReportAccess; the sidebar nav entry is hidden for anyone off the list (the APIs enforce regardless). Report v2 grew from 7 to 10 per-recipient sections: a three-period scorecard (every key metric: same period last year / previous period / now, with vs-period and vs-year ▲/▼ chips, red/green inverted for no-shows and cancellations), POS deep-dive (per-category trend chips, sales by area/till via pos_locations, top-15 sellers — widened from 5 on Pablo's follow-up), bookings behaviour (lead-time buckets + averages), academy fill rate (enrolled vs classes.max_pax — the 'event fill %' ask mapped to the data that actually exists; the parked Events feature has no tables) plus coach ratings (lesson_players.coach_rating), customers & loyalty (repeat-player %, all-time visit-frequency histogram), top customers (most frequent + most guests brought, names masked to 'First L.' in the email per Pablo's choice), and looking-ahead (next period already on the books with same-lead-point pace comparison, large groups and regulars coming). Weather + Thai-holidays context was explicitly deferred to v3 by Pablo. All delivered into the same open PR #1028 (his call: one review of the complete feature).
Alternatives considered
The access model mirrors the principle already used for the terminal's mark_bookings_paid RPC (DEC-307): don't widen a whole role tier when the business rule is about specific named people. The intelligence sections were chosen by walking a real best-in-class report against the data Setpoint actually has — every v2 section maps to existing tables (no new columns beyond the two feature tables), and the ideas with no data behind them (weather, review comments, event occasions) were deferred or mapped to the closest real equivalent rather than shipped hollow.
New, separate report (send-business-report edge function + facility_business_report RPC + report_subscriptions table + Admin Settings → Reports UI) rather than evolving the POS one. The POS daily email is left running untouched: this feature originally shipped a companion migration unscheduling its cron, dropped before merge once PR #1037 (Item Sales section, same week) showed the POS team is still actively investing in that email — retiring a report someone else is building is a product decision for its owners, not a side effect of shipping ours. Per-recipient rows carry frequencies (daily/weekly/monthly, combinable), a jsonb map of section toggles (financial/categories/payments/occupancy/bookings/academy/customers), a send hour, and an enabled flag; an hourly pg_cron dispatcher fires per distinct (facility, frequency, hour) and the function re-filters recipients itself. Money sections (financial/categories/payments) come EXCLUSIVELY from pos_orders/pos_payments — court fees and lessons settled at the till are already inside those totals — while bookings.booked_value_cents and academy.coaching_paid_cents are labelled operational values and never added to collected totals, which is what prevents double-counting. The email HTML has a single owner (functions/_shared/business-report-html.ts); the Admin preview/test-send proxies the edge function (which accepts the service-role key as a machine caller alongside the existing x-internal-secret) instead of duplicating the template in Next.js.
Alternatives considered
Follows Built-in first (pg_cron + edge function + Resend, the exact stack the POS report already proved), consumer-agnostic naming (report_subscriptions, no POS/TPS baked in), schema-reuse-with-justification (pos_report_settings checked and ruled out above — per-recipient rows are a genuinely different shape), and no-debt (one HTML template owner; the admin proxies rather than forks it). Requested and scoped live by Pablo (2026-08-20): Square's daily summary as the base, padel metrics (occupancy per hour/court) and club-area category sales on top, recipients/frequency/sections editable from Admin.
Merge Coaches, Classes and Packages into a single new route /{facility}/academy/setup, sidebar 8 -> 6: Overview · Lessons · Planning · Students · Reports · Setup, in that order. Setup is a TABBED page (Coaches · Classes · Packages · Academy, Coaches default), NOT a stacked one-pager — Nestor rejected the one-page layout explicitly. Above the tabs sit three clickable summary cards (Coaches / Classes / Packages) whose counts come from the SAME list endpoints the tabs below already call, fetched in one parallel wave; no summary API route was added. Each tab renders the EXISTING component unchanged: CoachesList, ClassesList, and the packages page's own catalogue/player-packages sub-tabs verbatim. A fourth Academy tab shows name/description/focus-areas/facilities as a read view whose Edit button opens the EXISTING AcademySettingsDialog — no new form. /academy/coaches, /academy/classes and /academy/packages remain live URLs and 307-redirect to /academy/setup?tab=<x>; /academy/coaches/[id] (coach detail) is untouched. The three thin *-page-client.tsx wrappers those routes used were deleted rather than left orphaned: the setup page resolves academy id, facility id and court count ONCE and passes them down, so the merge REMOVES three duplicate /api/admin/academies bootstrap calls instead of adding any. The name 'Setup' is provisional — chosen from five candidates Nestor reviewed in the mockup.
Alternatives considered
Industry check, by name: Stripe Dashboard puts Products, Coupons and Tax rates behind a single Product catalogue surface while Payments and Customers stay top-level; Shopify collapses everything configured-once into Settings and keeps Orders/Products/Customers in the primary nav; Linear does the same with team Settings versus Issues/Cycles. The consistent pattern is that navigation depth should track how often something is opened, not how many tables back it. Two honest caveats recorded rather than smoothed over. (1) The mockup's coaches summary reads '2 active · 1 on leave'. There is no on-leave state in the schema, AND GET /api/admin/coaches filters is_active=true, so inactive coaches are invisible to the admin UI entirely — an active/inactive split would have permanently rendered '· 0 inactive'. The card therefore breaks down by employment_type ('2 in-house · 1 freelance'), which is a real column already shown as a Type badge in the table directly below it. That the list endpoint silently hides deactivated coaches is a separate finding worth its own look; deactivating a coach makes them vanish rather than grey out. (2) The Academy tab shows 'N facilities linked' as a count, not the facility names the mockup draws, because the academies endpoint returns facility_count and nothing else — rendering names would have required the new API route this change was scoped not to add.
Middleware signs a minimal `{ id, exp }` claim for the user it just verified and forwards it to the handler on an internal request header (`x-setpoint-internal-identity` + `x-setpoint-internal-identity-sig`), HMAC-SHA256 with a 60s expiry, domain-separated by the label `setpoint/admin/internal-identity/v1.` and keyed on SUPABASE_SERVICE_ROLE_KEY (already required server-side, never exposed to the browser, so no new production env var and therefore no window where it is unset). Three properties make it safe. (1) The signature covers METHOD + PATHNAME as well as the claim, and the verifier recomputes it from the request it is actually serving — so a signed header is valid only for the exact request it was minted for and can never be replayed onto another route or verb. (2) Middleware strips every inbound `x-setpoint-internal-*` header on every request it sees, including the paths it deliberately does not authenticate (/auth, /api/v1, the external-auth webhook and lesson routes). (3) The matcher lists `/api/:path*` unconditionally, because the pre-existing image-extension carve-out is extension-based rather than directory-based and therefore also exempted any API URL a caller suffixed with one (`/api/admin/lessons/<id>.png` still resolved to the real dynamic route while skipping middleware entirely — no 401 gate and no header strip). getSessionUser() checks Bearer first (unchanged), then the signed header, then falls back to the original cookie/getUser() path. Failure is always 'slow', never 'open': missing secret, WebCrypto fault, bad signature, tampered claim, wrong route, or expired claim all return null and the handler re-authenticates exactly as before — nothing in the module throws, on either the signing or the verifying side. The claim carries ONLY the user id (~150 bytes): every /api/admin consumer reads user.id and nothing else, so getSessionUser returns a `SessionUser` (id guaranteed, everything else optional) rather than fabricating placeholder fields to satisfy the full Supabase `User` shape. Measured result: 2 GoTrue round trips per admin API call become 1. SCOPE OF THAT WIN, precisely: it applies to routes that authenticate through lib/auth.ts (requireFacilityAccess / requireSessionUser / withFacilityAccess), which is the large majority. It does NOT apply to 18 route files (21 call sites) that call supabase.auth.getUser() directly and never touch lib/auth.ts — crm/campaigns/*, crm/customers/[id]/notes+tags, crm/segments, crm/tags, crm/templates, crm/email-settings, bookings, facilities, facilities/my, facility-contacts. Those still pay the duplicate hop and are a target of the follow-up PR alongside the facility-lookup merge.
Alternatives considered
Industry check, by name: Clerk's middleware hands the handler a cryptographically verifiable token (verified networklessly against JWKS), never a bare user id. Auth.js/NextAuth never emits an identity header at all — it carries a JWE session signed with AUTH_SECRET and decodes it locally in both layers. Supabase's own SSR docs simply accept the second getUser() hop because they have no trusted channel between middleware and handler; that gap is what this closes. The consistent pattern across all three is that identity crossing a layer boundary is always something the receiver can verify, never something it must assume. So: signed, not bare. Note the honest limit of this change — it removes the duplicate AUTH hop (2 GoTrue round trips -> 1), but the audit's 'collapse 4 -> 1' framing also counted the per-route `facilities`-by-slug lookup, which lives in the 73 route files that need `facility.id` for their own queries, not in requireFacilityAccess(). Joining it to the role lookup cannot be done without editing those routes, so it is deliberately left to a follow-up PR; every current caller passes a facility UUID, and shipping an unused slug-accepting code path here would have been dead code. ACCEPTED RISK, recorded rather than fixed now: the signing key IS the service role key, so rotating that key also rotates this signature. That is safe by construction (in-flight headers stop verifying and every request falls back to the getUser() path — slower, never open) and it is why no new env var was introduced, but it does couple two concerns. The follow-up, if the coupling ever bites, is to derive a dedicated subkey via HKDF from the same secret with a distinct info string: same zero-new-env-var property, proper key separation. Not done now because HKDF adds a second derivation step whose only benefit today is hygiene. Two review corrections also worth recording: the first implementation signed the ENTIRE Supabase User (~1-2KB) and needed a payload-size guard whose failure mode was a silent slow path — replaced by the minimal claim; and the first version did not bind method/pathname, which is what made the matcher hole exploitable as a replayable bearer credential rather than merely a missing 401 gate.
Rewrote both booking_players policies as a correlated EXISTS keyed on `b.id = booking_players.booking_id` (bookings_pkey, indexed) instead of the unbound IN-subquery. The SELECT policy's EXISTS body is deliberately bare — `EXISTS (SELECT 1 FROM bookings b WHERE b.id = booking_players.booking_id)`, no copied status/own/role predicate — a first draft hand-copied bookings' own SELECT predicate into it, but /code-review caught that this duplicates logic that drifts silently if bookings' own policies ever change; a bare EXISTS against a single primary-key-matched row already transitively evaluates bookings' OWN RLS at the exact same indexed cost (confirmed via EXPLAIN: ~1.1ms either way), so it inherits 'readable via its parent booking' automatically instead of maintaining a second copy of the rule. The ALL (manage) policy keeps its own explicit owner/admin/manager check — that's a genuinely distinct, independent business rule (who may WRITE, not who may read), not a copy of bookings' read policy, so no drift risk there. Verified via EXPLAIN ANALYZE in a transaction against prod that was rolled back before ever touching real data: the identical 10-booking_id lookup dropped from 2554ms to ~1.1ms (~2000x) — now an indexed nested-loop instead of two full sequential scans. Applied via the standard new-migration approved deploy path after local verification (E2E: tc-271, which exercises booking_players through the terminal's own split-by-player-count flow and directly re-loads the Bookings tab that was reportedly broken, passed cleanly end-to-end, both before and after the bare-EXISTS simplification).
Alternatives considered
This was never a regression from a recent change — it's a correctness/scale gap in RLS that was always going to fail once a facility's cumulative booking count grew past whatever threshold made a full-table scan exceed 8 seconds. The other three 'readable via parent' policies in this codebase (facility_tickets, lesson_players, lesson_courts) all correctly scope through `user_facility_role()` on a specific facility/lesson id; booking_players was the one outlier written as a bare, unscoped table reference. Same lesson as DEC-289/DEC-306: an invariant ('you can read this child row if you can read its parent') is safe as a description, but the SQL that implements it has to actually be bounded by something indexable — not merely 'technically correct because RLS on the referenced table will filter it eventually.'
Root-cause fix: a new SECURITY DEFINER RPC, `mark_bookings_paid(p_booking_ids uuid[])`, scoped to exactly the status='completed' + payment_status='paid' transition, checking each booking's OWN facility_id via user_facility_role() against ['owner','admin','manager','staff','cashier'] — narrower than widening the bookings UPDATE policy itself, which would let staff/cashier edit any column on any booking (time, court, price) via a direct REST call. checkout-dialog.tsx's markBookingsCompletePaid helper now calls this RPC instead of a raw update. The cron fallback got a raw equivalent write instead (admin/service-role client already bypasses RLS, so the RPC's role check would be redundant there), added after it closes a ticket, parsing bookingIds out of the ticket's cart_json the same way checkout-dialog.tsx does; its failure path returns a `bookingSyncFailures` array in the cron's JSON response rather than only console.error()-ing, since a cron has no operator to toast. TC-278's E2E coverage was also switched from an owner-role seed account to a staff-role one — an owner-role test could never have caught the RLS gap in the first place, since owner was never blocked by the policy. A SECOND /code-review pass (after this fix's first version routed apps/pos's useUpdateBookingStatus 'Mark completed' through the same RPC) caught two more issues: that action has no payment context at all — it sits next to 'No show'/'Cancel' on a confirmed booking with no ticket or charge involved — so forcing payment_status='paid' there would have silently mislabeled genuinely-unpaid completions as paid; apps/pos's fix was reverted to a plain status-only update, left as its own separate product decision (does POS 'Mark completed' ever mean paid?) rather than assumed here. The same review pass also caught that the untouched sibling branch of that same function was writing `bookings.updated_at`, a column that doesn't exist on the table at all (confirmed against the CREATE TABLE) — pre-existing, but in the exact function this PR touched, so removed.
Alternatives considered
The bug wasn't really 'payment_status is missing from one update call' — it was 'the write path for this update assumed a caller role that isn't who actually runs the till'. A patch that adds the missing field without checking who's actually calling it would have shipped a fix that only works for the roles that were never broken. Mirrors the get_facility_customer/admin_search_facility_customers RPC precedent (20260728072623): a narrow SECURITY DEFINER function scoped to one specific, well-understood transition is the least-privilege way to let a lower-privileged role perform a trusted write, without touching the general-purpose RLS policy everything else on that table relies on. The apps/pos reversal reflects a narrower point: 'the same bug exists in a sibling module' and 'the same fix applies in a sibling module' are different claims — the first was true, the second wasn't, because that module's action carries no payment semantics to sync in the first place.
Extended DialogDescription itself (dialog.tsx) with an opt-in `visible?: boolean` prop, defaulting to false (sr-only, unchanged for the ~25 existing call sites using it for genuine taglines). When a dialog's description IS real, data-carrying content — a confirmation code, an account identifier, a live operation's status — `<DialogDescription visible>` renders that SAME node as normal visible text instead of sr-only, staying wired to aria-describedby with no duplicate node and no double screen-reader announcement. Fixed at 7 call sites: booking-detail-dialog.tsx (the reported bug), edit-api-key-dialog.tsx, delete-customer-dialog.tsx, merge-customer-dialog.tsx, add-credits-dialog.tsx, team-member-dialog.tsx, and lesson-roster-panel.tsx (consolidated its existing two-node workaround down to one). match-detail-dialog.tsx has the identical bug but was deliberately left UNFIXED and reverted mid-PR: CLAUDE.md marks the Matches feature 'PARKED — do not modify without an explicit go-ahead,' and touching it here would have been an undiscussed drive-by edit to parked code, caught by /code-review. NOT YET FIXED, logged as follow-up (not blocking this PR, per explicit direction to close this out): a further /code-review pass found at least 3 more live instances outside this PR's original scope — player-packages-list.tsx's 'Cancel Package' dialog (destructive action; the highest-priority of the three, since it hides which player/package/forfeited-session-count is about to be cancelled), enroll-user-dialog.tsx, and group-detail-client.tsx. Also NOT fixed: `sheet.tsx`'s SheetDescription has the same structural pattern with no equivalent opt-in; no lint/type-level enforcement exists to catch a future dialog shipping real data sr-only again (the alternatives below explain why `visible` stayed opt-in despite this gap).
Alternatives considered
Patching call sites individually (the pattern this PR started with, and the pattern lesson-roster-panel.tsx had already independently invented) treats the symptom, not the cause — CLAUDE.md's own 'Architecture-first, not patches' bias points at fixing the shared primitive instead. The opt-in `visible` prop keeps the safe default (sr-only, matching the majority real use case: decorative taglines) while giving call sites with genuine data a single, correct, accessible way to show it — one node serves both audiences, wired to aria-describedby either way. Verified live via three throwaway Playwright scripts across this PR's iterations: the sr-only Description measures 1x1px (Tailwind's clip-path: inset(50%) implementation), the visible variant measures 438x20px, and the dialog's own aria-describedby attribute correctly points at the visible node's id — confirming no duplicate node and no broken accessibility wiring. /code-review caught, across five rounds on this PR: the original bug itself; a stale-code path in TC-077's own regression test (dialog.textContent() matches sr-only text too, so the original test would have kept passing on the very bug it existed to catch); a route bug in that same test that meant its calendar-UI check had never actually run on any prior version of this suite (navigated to /dashboard, which has no bookings, instead of /calendar); and the three additional un-fixed instances (add-credits, team-member, lesson-roster) found by re-scanning the codebase for the same pattern after the primitive-level fix landed.
Resolved the removal-semantics ambiguity by asking directly: removing the court from the ticket does NOT cancel the actual booking — the reservation stays confirmed and the court slot stays reserved, it only detaches the fee from this cart (useful if it was added to the wrong ticket and needs to be re-added to the right one). Redesigned the tap flow: the booking card now opens the same CartItemModal every other cart item uses (item info, auto/engine discounts, then actions) instead of jumping straight into SplitBillModal — CartItemModal already had partial isBooking-awareness (a bare Remove button, discount section explicitly excluded) suggesting this was the intended long-term shape. Extended it: the manual discount section is now offered for booking items too (same setItemDiscount()/splitLineItemWithDiscount() logic every other item already uses — no new discount mechanism). The actions row gained a proper isBooking branch: Split cost (opens SplitBillModal, pre-selected via DEC-301's initialSelectedItemId) + Remove, and Remove now requires a second confirm tap (with inline copy stating the booking stays confirmed) before removeFromCart() actually fires — mirroring the existing Cancel/confirm shape already used elsewhere in the app rather than inventing a new confirmation pattern. Also removed the now-redundant 'Split cost' toggle button inside SplitBillModal's by-items panel for booking items specifically — with Move hidden (DEC-299), it was a button that's always the only, already-active option and does nothing when tapped, which was exactly the 'two confirm buttons' confusion Alba flagged; only the explanatory hint text remains, and the real confirm action is the existing 'Apply N assignment' button.
Alternatives considered
This is the direct next step after DEC-301 — that fix made the card interactive but only in the narrowest possible way (jump straight into a single split screen), and the very next real usage surfaced the two gaps a full action set naturally covers (discount, remove) plus the redundant-button confusion of a single-purpose entry point bolted onto a multi-action screen. Reusing CartItemModal's existing discount/removal machinery instead of building parallel logic keeps the 'one behavior, one place' principle DEC-301/BUG-110's own postmortem argued for. Two /code-review rounds caught five real gaps, all fixed at the root: (1) Remove only called the existing removeFromCart (in-memory Zustand only) — cart_json never got re-persisted, and facility_tickets.booking_id (which bookingInfoFromTicket reads directly, independent of cart_json) was never cleared, so loadCartFromTicket's hasBookingItem re-injection logic silently brought the booking back on the very next reload. (2) bookingCardAmountCents() (DEC-301) only recomputed the live price when item.splitInfo was set — a manual discount via this PR's newly-enabled discount section leaves splitInfo undefined, so the card kept showing the stale bookingDisplayPriceCents even though the modal showed the discount as applied; fixed to prefer the live total whenever EITHER splitInfo OR an active appliedDiscount is present. (3) SECOND round: the cart_json save and the booking_id clear were two independent, non-transactional Supabase calls (even awaited together) — a partial failure of either reproduces the exact re-injection bug (1) just fixed, or orphans a cart line with no FK backing it; combined into one atomic UPDATE statement on the single ticket row (useDetachBookingFromTicket now takes {ticketId, cartJson}). (4) Removing a booking never cleared the Zustand store's activeBookingInfo, so checkout-dialog's 'mark completed on Charge' bookingIds union (cart bookings PLUS activeBookingInfo?.bookingId) kept including the just-detached booking's id — charging the REMAINING cart items without reloading would have silently marked the removed, never-charged booking 'completed', directly contradicting this decision's own stated guarantee. Added clearActiveBookingInfo() to the store, called alongside the removal. (5) The card's tappability gate (isUnpaid===true && amount>0, inherited from DEC-301's split-only design) blocked reaching Remove entirely for a prepaid, zero-balance, or booking-unlinked ticket — exactly the cases this decision says Remove should cover ('added to the wrong ticket'), since Remove has no payment-state dependency at all. Moved the eligibility check to gate only the Split button (canSplitBooking, computed inside CartItemModal from the live item state) — the card itself is now always tappable, matching how every other cart item already behaves. Verified live via TC-274 (tap-opens-CartItemModal-first flow), TC-274c (discount applies AND the cart card reflects it — not just the modal; Cancel backs out without removing; confirming removes AND survives a reload, with booking_id confirmed NULL in the DB), TC-274d (a PREPAID booking's card stays enabled, offers Remove but no Split button, and removal still works), and TC-272 (the by-items split screen no longer renders a Split cost button, only the hint). THIRD /code-review round caught five more gaps: (1) CRITICAL — useDetachBookingFromTicket's atomic write took the caller's raw cart array as cartJson, the exact plain-array-write bug persistOwnTicket() (split-item-detail-modal.tsx) already carries a comment warning against: it silently drops the ticket's trailing discount_meta pseudo-item, deleting an active order-level discount the moment a booking is removed. Fixed by having the mutation itself read cart_json fresh from the DB and re-encode with buildTicketCartJson(remaining, discount), matching persistOwnTicket's read-transform-write shape, instead of trusting a caller-supplied array. Fixing this surfaced a SECOND, undetected bug in the same code path: filtering the removed item by cart-item id doesn't work, because loadCartFromTicket mints a fresh synthetic id (cli-restored-…) for the booking line item on every cart restore, decoupled from whatever id is actually persisted in cart_json — a live item's id essentially never matches its own DB row's id. An id-based filter silently matched nothing (booking_id cleared, but the booking line item stayed in cart_json). Caught by this PR's own local E2E run (TC-274c's reload-and-check-DB step), not by the review itself — a reminder that a review's fix can introduce a new bug the review pass never gets a chance to see. Fixed by filtering on isBooking instead of item id (a ticket has at most one booking-linked item, since booking_id is a single scalar FK, so this is unambiguous). (2) CRITICAL — packages/common/src/check-in.ts sets a genuinely prepaid booking's unitPriceCents to 0 at check-in (bookingDisplayPriceCents alone holds the real settled amount — the cart must never charge for it again), but CartItemModal's own header ('… each' / 'Total') read unitPriceCents-derived values directly, so simply opening the modal on a prepaid booking (already unconditionally tappable per this decision's own round-2 fix) showed '฿0.00 each' / 'Total ฿0.00' regardless of any discount — a direct regression of the BUG-110/AC-9 price-display fix, and worse than the review itself described (not just 'after a discount' but on every open). Fixed by reusing bookingCardAmountCents(item) — already isPrePaid-aware — for the modal's own total instead of the unitPriceCents-derived getLineItemTotal, and by hardening bookingCardAmountCents itself to check isPrePaid FIRST and short-circuit straight to bookingDisplayPriceCents, rather than only reaching the correct value via the absence of splitInfo/appliedDiscount. Also gated the discount section itself away from a prepaid booking (isPrePaid && isBooking) — discounting an amount that's already 0 and already settled elsewhere is meaningless, and was the only reachable way to put a prepaid booking into the hasLiveAdjustment state the fix above guards against. TC-274d's own seed data had unitPriceCents: COURT_FEE for its 'prepaid' booking (not check-in.ts's real 0), so the test was passing without ever exercising the actual bug path — corrected to unitPriceCents: 0 and added an explicit assertion that the modal shows ฿1,400.00, not ฿0.00. (3) handleRemoveBooking applied removeFromCart()/clearActiveBookingInfo() optimistically before awaiting the detach mutation, with no rollback on failure — a network/RLS failure would leave the DB unchanged while the UI already showed the booking gone, reachable through the same 'removal doesn't stick' bug class already fixed twice, just via a new trigger. Fixed: snapshot cart + activeBookingInfo before the optimistic update, restore both in a catch around the mutation call if it throws (the mutation's own onError still toasts the failure; the catch here only reverses the UI-side effect). (4) The hand-rolled confirmingRemove two-button footer duplicated an existing, purpose-built ConfirmDialog component (Radix AlertDialog, already used for catalogue-delete confirmations) despite this decision's own rationale claiming to mirror 'the existing Cancel/confirm shape already used elsewhere' — it didn't actually reuse anything. Refactored to render ConfirmDialog itself (extended with optional confirmTestId/cancelTestId props so it stays E2E-testable), which also gets AlertDialog's blocked-outside-click-dismiss behavior for free — a stronger 'double ask' than the original inline footer had. (5) useDetachBookingFromTicket's original write used a (supabase as any) cast — a new instance of a pattern CLAUDE.md explicitly says never to add. Removed it: the terminal app's createClient() is already generic over its Database type and facility_tickets/cart_json/booking_id are all present in it, so the call type-checks cleanly with no cast at all (npx tsc --noEmit -p . passes). The other ~19 (supabase as any) casts already in this same file predate this PR and are left as-is, matching CLAUDE.md's 'clean up when touching those files, don't add new ones' — not 'rewrite every pre-existing cast in a file you touch at all'. All five re-verified live: full 8/8 E2E pass across tc-272, tc-273, tc-273b, tc-274, tc-274b, tc-274c, tc-274d. FOURTH /code-review round caught three more gaps, all in the same 'discard live state' family as round three's own fixes: (1) the round-three fix for the discount_meta-drop bug (reading cart_json fresh from the DB before writing) solved that bug by trading it for a worse one — reading 'the last SAVED state' and writing that back minus the booking silently discards ANY of the cashier's own unsaved local edits that exist in the live cart but were never explicitly Saved/Charged (an added item, a qty bump, a not-yet-saved order-level discount), a normal terminal state (hasChanges is a real, common condition in this app, not an edge case). Fixed at the root by reverting useDetachBookingFromTicket to accept a caller-supplied cartJson again — but this time the CALLER (handleRemoveBooking) is the one responsible for correctly encoding it: it reads the LIVE Zustand cart (already minus the booking item, via the existing local removeFromCart) plus the LIVE pendingDiscount, and encodes both through buildTicketCartJson before calling the mutation — closing BOTH the original round-three discount_meta bug (now genuinely encoded, not silently dropped) AND this new unsaved-edits bug (now genuinely the live client state, not a DB re-read) in the same write, without needing the id-matching workaround from round three at all (the live cart, by construction, already has the booking item removed under whatever id it locally holds). (2) handleRemoveBooking silently skipped the whole persistence path — local-only removeFromCart with no error, toast, or comment — whenever activeTicketId was null, which is reachable in practice: customer-selector.tsx's handleSelect() clears activeTicketId (via clearActiveTicket()) with no unsaved-changes guard whenever staff pick a different, ticket-less customer while a booking-bearing ticket's cart is still loaded, leaving the booking item visibly stranded in the cart with no ticket context. Since a booking item only ever enters the cart via an existing ticket (check-in creates the ticket alongside it), a null activeTicketId here is always an anomaly, never a legitimate state — so silently no-op-ing past the DB write (while the UI still reports success) was wrong. Fixed by treating it as an explicit error: handleRemoveBooking now bails out with a toast ('reopen its ticket first') and does nothing else, rather than removing the item locally and closing as if it worked. The underlying gap in customer-selector.tsx (handleSelect has no hasChanges guard at all, unlike its own handleClear) is real but predates this PR and isn't specific to booking actions — logged as follow-up, not fixed inline, to avoid scope creep into an unrelated screen under an already-long review cycle. (3) the discount section's booking-specific gate (isPrepaidBooking = isBooking && isPrePaid) treated an undefined isPrePaid as 'not prepaid' — i.e. eligible for a discount — which is wrong for the same ambiguous/unlinked-booking state (isPrePaid/isUnpaid both undefined, reachable when a ticket's linked booking was later deleted) that canSplitBooking already excludes via a strict isUnpaid===true check. Fixed by unifying the rule: the discount section now uses the same isUnpaid===true eligibility as Split, so an ambiguous booking offers neither. Regression-tested live: a new TC-274e seeds a dedicated fourth ticket, bumps a non-booking item's quantity via the modal's stepper without ever tapping Save/Charge, removes the booking, reloads, and asserts the unsaved qty change survived (not reverted to a stale 1) — the exact failure mode finding (1) described. TC-274/274b/274c/274d re-verified unaffected. Full 9-test suite (tc-272, tc-273, tc-273b, tc-274, tc-274b, tc-274c, tc-274d, tc-274e) passing before merge. FIFTH /code-review round found one more real correctness bug plus nine lower-severity cleanup/architecture findings — no CLAUDE.md violations this round. Fixed the real bug and two cheap, high-value cleanups; the rest documented here as accepted trade-offs rather than iterated further, given four prior rounds had already converged on genuine correctness issues and this round's remainder was explicitly non-correctness. REAL BUG (fixed): round three's fix for the ฿0.00-prepaid-display bug set originalUnitPrice ('… each') equal to lineTotal for every booking, not just prepaid ones — so a DISCOUNTED (non-prepaid) booking's 'each' silently tracked the discounted total too, identical to Total, with no strikethrough (only engine discounts trigger one) — hiding that a discount was ever applied and what the original court fee was, unlike every non-booking item (whose 'each' always stays the discount-agnostic gross unitPriceCents). Fixed by only falling back to bookingDisplayPriceCents when isPrePaid; a normal (non-prepaid) booking's unitPriceCents is never touched by setItemDiscount (only appliedDiscount is) and already correctly holds the reduced per-split share for a split booking, so unitPriceCents+modifierTotal is the right 'each' value in both of those cases, matching non-booking items exactly. Verified live: TC-274c now asserts the modal shows Total ฿1,260.00 AND 'each' ฿1,400.00 after a 10% discount, not both collapsing to the same number. FIXED (cheap, high value): useDetachBookingFromTicket had no logActivity call, unlike every other mutation in this file (ticket_opened, ticket_cart_updated) — added one (booking_detached_from_ticket), matching the existing fire-and-forget pattern, so 'who removed booking X from ticket Y and when' has an audit trail for what this decision's own rationale calls 'real money tied to a real reservation'. Also unified canSplitBooking and the discount section's eligibility check into one shared isBookingUnpaidConfirmed const (both were independently re-deriving liveItem.isBooking && liveItem.isUnpaid===true in the same file) — cheap, same-file dedup closing the exact 'two independently-maintained eligibility predicates drift apart' risk this PR's own earlier rounds hit for real (bookingCardAmountCents, DEC-298). ACCEPTED, not fixed (documented follow-ups, all explicitly non-correctness / pre-existing / out-of-scope for this PR): (1) canSplitBooking (cart-item-modal.tsx) and split-bill-modal.tsx's own cartItems filter (!isPrePaid) are still two independently-maintained predicates for booking-split-eligibility ACROSS files — real duplication risk, but extracting a shared cross-file helper touches a second, already-hardened (DEC-299, four review rounds) file under an already-long review cycle; flagged for a future dedicated pass. (2) the mutation-failure rollback in handleRemoveBooking uses a raw useTerminalStore.setState() rather than a named store action — works correctly and is commented, just not as discoverable as a named action; low value relative to the churn of adding one. (3) cart-sidebar.tsx's new splitBillItem is a third parallel 'which modal is open' state variable alongside selectedCartItem/splitMoveConfig rather than one discriminated union — real architectural debt, but collapsing three modal-routing states into one is a materially bigger refactor than this PR's scope (booking action menu), risking new bugs in the two OTHER item-type flows (regular/split) that aren't broken today. (4) useDetachBookingFromTicket's onSuccess unconditionally invalidates ['pms-bookings'] even though only facility_tickets changed — wasteful, not wrong; removing it risks a stale bookings-list view somewhere that reads ticket-association through that query, unverified either way under time pressure, so left as the safe-but-slightly-wasteful default. (5) the new/expanded E2E tests use fixed waitForTimeout() calls instead of auto-waiting expect(...).toBeVisible() — consistent with how EVERY test in this same file already reads (a pre-existing file-wide convention, not something this PR introduced), so left matching the surrounding style rather than partially converting just the new tests. (6) split-item-detail-modal.tsx's handleQty has the same 'local store mutation then async DB persist' shape as this PR's handleRemoveBooking but no equivalent optimistic-rollback-on-failure — a real related gap, but in a different feature/file entirely, out of scope for DEC-302. (7) the two new E2E seed blocks (prepaid, unsaved-edit tickets) copy-paste the same insert-booking-then-insert-ticket scaffolding two earlier blocks in the same beforeAll already used — a shared seeding helper would be cleaner, deferred as test-file cleanup rather than blocking a production-code merge.
Fixed the price display to match checkout-dialog.tsx: only isPrePaid hides the price behind 'Paid'; every other case (including the normal unpaid cash/pay-at-club state) shows the real formatted price. Made the card tappable when unpaid (a settled booking has nothing left to split, so it stays inert) — tapping it opens SplitBillModal directly in 'by-items' mode, the SAME modal and safety logic (resolveItemAction forcing split-only, hidden Move button, DEC-299's hint) the Charge flow already uses, rather than routing through CartItemModal + SplitMoveModal (the OTHER split/move path reached from a regular item's card, which has zero isBooking awareness and would have needed the same booking-safety logic duplicated a second time).
Alternatives considered
The price bug is the same lesson as DEC-298 (isExternallySettled) and this same branch's resolveItemAction dedup: the same business rule re-implemented in two places drifts the moment one copy gets fixed and the other doesn't. The split/move-access fix follows the principle from DEC-299's own rationale — reuse the already-hardened, already-tested modal instead of building or extending a second copy of its safety logic. Two /code-review rounds (8 parallel finder-angles each, several independently converging on the same root cause) caught real gaps, all fixed at the root rather than patched: (1) SplitBillModal auto-selected cartItems[0] on open, unreliable once a split-off booking copy lands appended (useSplitItemToTickets), not prepended — added initialSelectedItemId, threaded from the tap. (2) price-display was hand-copied into cart-sidebar.tsx as a second implementation of checkout-dialog.tsx's rule — the same pattern that CAUSED this bug, reproduced by its own fix; extracted bookingCardAmountCents() (line-item-pricing.ts). (3) the card was a plain div with cursor-pointer but no keyboard semantics; switched to a real <button disabled={!isTappable}>. (4) tappability was computed independently in two places; collapsed to one source of truth in CartItem. (5) MOST IMPORTANT, caught by a second review round and independently confirmed by two separate finder-angles: bookingCardAmountCents() as first written preferred item.bookingDisplayPriceCents — a value set ONCE at check-in (packages/common/src/check-in.ts) and never touched by applySplitToItem — over the live computed total, so after ANY split the card would show the stale full original price forever, the exact 'wrong amount on a booking card' bug class this whole fix exists to close. Fixed: the helper now prefers the live effectiveLineTotalCents() whenever item.splitInfo is present (the signal a split has happened), and both call sites were simplified to a single-argument bookingCardAmountCents(item) instead of threading a total that could theoretically disagree with the item it's computed for. (6) isTappable required only !isPrePaid, which treats an ambiguous state (isPrePaid/isUnpaid both undefined — reachable when a ticket's linked booking is deleted, per loadCartFromTicket's fallback path) as tappable by default; tightened to require isUnpaid === true explicitly, so ambiguous state stays inert. Deliberately NOT extended in this pass: checkout-dialog.tsx's own booking row stays non-interactive (no tap-to-split there) — Split Bill is still reachable from checkout via its existing buttons, and generalizing tap-to-split + isTappable into a shared cross-file pattern is real, correctly-identified follow-up work, not a same-day requirement for the live bug that was reported. Verified live via TC-274 + TC-274b (added specifically to catch the post-split staleness bug in the seed data, not just the happy path): the card shows ฿1,400.00 pre-split, opens Split Bill with the correct item pre-selected, and — seeded already split — correctly shows the live ฿700 remaining share, never the stale ฿1,400.
A page-size cap (50 bookings) via a real useInfiniteQuery, plus a 'Load more' button. usePmsBookings(range, pageSize) pages with a keyset cursor on (start_time, id) — .or("start_time.gt.<cursor>,and(start_time.eq.<cursor>,id.gt.<cursorId>)") rather than OFFSET/LIMIT, so a booking inserted (or one that changes status) between fetches can't shift a later page's boundary and cause a duplicate or skipped row; getNextPageParam returns the last row's (start_time, id) or undefined once a page comes back short. Each 'Load more' click fetches and enriches only the NEW page, never re-fetching or re-enriching pages already loaded. Switching dayFilter/customRange changes the query key (different from/to), so the page automatically starts fresh at page 1 with no manual reset needed. Search/status/type filters auto-load the rest of the window (so a real match on a not-yet-loaded page isn't silently missed) but capped at 10 pages (500 bookings) — past that, search only covers what's loaded and the operator pages in more deliberately, with the status-count badges showing a '+' suffix whenever more of the window is still unloaded so a partial count is never presented as the true total.
Alternatives considered
PR #986 fixed the two bugs that made a SCOPED range slow (sequential waterfall, missing indexes) but never bounded fetch size — the fix for a range that returns hundreds of bookings has to be a smaller fetch, not a faster one. Four /code-review passes on this same PR each caught a way the fix could regress toward the original bug or a related correctness gap (cumulative re-fetch cost on 'Load more'; unbounded auto-load-on-filter; a perpetual background timer re-fetching every loaded page; an OFFSET-pagination race under concurrent inserts) — each fixed at the root (incremental useInfiniteQuery paging; an explicit ceiling on filter auto-load; dropping the interval timer in favor of event-driven invalidation; a keyset cursor) rather than patching the specific symptom the reviewer found. One review finding was accepted as-is rather than fixed: this PR bundles two unrelated fixes (this pagination fix and DEC-299's split-bill booking-fee restriction) into one branch/PR, against the repo's normal one-feature-one-branch convention — both were reported together in the same live message from Alba, and splitting them now would cost another full setup/build/E2E/review cycle to re-ship fixes that are already verified and waiting; a future occurrence of two unrelated bugs reported together should still default to separate PRs when there's no urgency forcing the trade-off.
Restrict the UI: a booking line item (isBooking) can only be split, never moved. curAction resolves to "split" unconditionally for a booking item (ignoring any stored itemAction), the 'Move to ticket' button isn't rendered for one, and a short hint explains why ('A booking fee can only be split, not moved — it stays on this ticket'). Added a defense-in-depth guard in handleAssignItems too — moveEntries filters out any item.isBooking, in case stale itemAction state ever slips through some other path.
Alternatives considered
A booking's court fee is inherently tied to the booking record, not an arbitrary transferable cart line — the ticket linked via booking_id is the one system-of-record downstream code (check-in status, payment tracking, reporting) reads. Splitting divides cost among payers while keeping that anchor intact; moving breaks the anchor with no compensating update anywhere else in the codebase. Verified live via TC-272: selecting the booking item offers Split cost only, and after applying, the organizer's (booking-linked) ticket keeps its half-share while the other ticket gets its own half — nothing disappears.
Root-cause fix in protocol/server: payment_method only flips to 'external' when external_ref is present AND method_type is NOT 'pay_at_venue' — matches the meaning DEC-160's own follow-up migration (20260727025048) already committed to in writing: "'external' keeps its current meaning — an OpenRally consumer collected the money and reported it back." A pay_at_venue booking stays 'pending', which is what it actually is. Admin's booking-detail-dialog override-warning gate, payment-method label, and external-ref display were all switched to call a single exported isExternallySettled(paymentMethod, externalPaymentMethodType) (packages/common/src/check-in.ts) instead of each re-deriving its own condition — the same function derivePricing() already calls internally for check-in pricing, computed once per render as isSettledExternally. /code-review caught that the first pass of this fix used a LOOSER admin-side check (paymentMethod==='external' && type!=='pay_at_venue', i.e. treating credit/other as settled) that disagreed with derivePricing()'s pre-existing, narrower EXTERNAL_SETTLED_TYPES=['prepaid','comp'] (which treats credit/other as unpaid) — a booking could have shown 'already paid' in admin while still being charged at check-in. Sharing one function closes that gap by construction: credit/other now consistently read as NOT settled everywhere (label says 'Pay at club', no override warning, still charged at check-in) until someone confirms their real settlement semantics and updates the one shared list. Already-mistagged rows in production display and behave correctly the moment admin deploys — no data backfill required to resolve the immediate report, though one (payment_method='external' AND external_payment_method_type='pay_at_venue' → 'pending') is still worth running for reporting cleanliness.
Alternatives considered
The three separate booking-confirmation RPCs (court via Rust, plus two lesson/session SQL RPCs found during this investigation) each independently reimplement 'what does external mean' with subtly different logic — the same fragmentation pattern DEC-291/292 already found and fixed on the terminal pricing side this same day. This fix corrects the one call path directly implicated by the live report (court bookings); the lesson/session RPCs carry a related but distinct bug shape (one of them writes the literal method_type string into payment_method, which isn't even a valid enum value) and were intentionally left untouched pending a dedicated pass rather than a rushed copy-paste fix under time pressure.
Fixed all of them in this PR rather than filing follow-ups. Two were financial-correctness bugs matching the exact bug class this PR exists to eliminate (Request-Payment could generate a link for the wrong amount; ticket-restore could silently re-charge an already-settled booking) — shipping the feature while leaving known instances of its own bug alive would defeat the point of the fix. Also exported derivePricing() from check-in.ts so terminal-store.ts calls the one real implementation instead of a second hand-rolled copy, closing off the class of bug rather than just this occurrence.
Alternatives considered
A single shared predicate/selector — not a rule to remember to apply in N places — is what makes this a one-edit-point fix instead of a partial migration. The review's own root-cause note: this class of bug recurs specifically because the check-in pricing question is computed independently everywhere it's needed.
Ships as a pre-fill helper on the EXISTING split-bill flow ('Split for this booking's N players' banner, shown only when the ticket's booking has ≥2 booking_players) — not a new standalone feature. It pre-creates and pre-selects tickets from booking_players, reusing checkInPlayer's own resolveCustomerId/findOrCreateProfile pattern for profile resolution, then hands off to the exact same manual-confirm split action every other split already uses. Only the initial selection state is new; 100% of the actual split mutation path (useSplitBillToTickets, bulkApplySplitToBill) is shared, untouched code.
Alternatives considered
Minimal-surface-area addition — new code is confined to selection pre-fill; the split confirmation, persistence, and revert paths are all pre-existing and already tested.
Kept the Court Booking card's distinct visual treatment (icon, badge, separate section above the regular item list) — only the price/badge logic changed (isPrePaid → 'Paid' badge; every other state → the real formatted price, never the booking ref/dash).
Alternatives considered
Lower-risk, and the split/discount participation (AC-5 through AC-8) is entirely a financial-computation change, not a rendering one — the card's identity in the UI and its inclusion in the money math are separable concerns.
Formula corrected to `isPrePaid ? 0 : price` for the two court flows (checkInBooking, checkInPlayer) — matching the pattern checkInLessonStudent already used correctly (only isPrePaid zeros the price; DEC-147–149 govern its own per-student payment_method logic). checkInLessonStudent itself is left untouched — its pricing already worked correctly, and its per-student payment_method shape isn't a generalization target for this PR (verified via existing E2E coverage, tc-113/tc-114, still passing after this branch's changes).
Alternatives considered
Minimal, root-cause fix scoped to where the actual bug lives. Regression-tested by keeping the lesson E2E suite green rather than assuming safety.
derivePricing() reads bookings.payment_status as the primary settled/unsettled signal (isPrePaid when paymentStatus === 'paid'), with payment_method (stripe/credits/package) and payment_method='external' + external_payment_method_type ('prepaid'|'comp') as secondary always-settled cases. No new column, no migration — payment_status and external_payment_method_type already exist and are already written by the admin UI and the OpenRally write path; the bug was purely that check-in ignored them.
Alternatives considered
Schema-reuse-before-new-columns is a standing engineering bias for this repo — the field this PR needed to read was already sitting right there, unread. Zero migration risk, zero new state to keep in sync.
Refuse. PATCH /api/admin/bookings now returns 409 with the lesson_id and a pointer to PATCH /api/admin/lessons/[id] when a reschedule field (court_id, start_time, end_time, duration_minutes) targets a booking that is a lesson leg. Cancel and no_show keep their existing cascades — those are state transitions the lesson route already models identically. Separately, the lesson dialog's slot grid stopped labelling every unavailable time 'outside coach hours': a time inside the coach's own windows that the coach-scoped endpoint still refuses is now labelled 'coach not free', so the grid no longer contradicts the 'Coach available 11:00 AM–12:00 PM' banner directly above it.
Alternatives considered
The repair direction (scripts/repair-lesson-booking-desync.sql) is the mirror of the same reasoning: the booking is what the operator dragged and what the calendar shows, so the booking wins and the lesson is realigned to it, with availability_override set because these were operator-forced off-schedule moves — which is exactly what that flag records. Refusing an edit the operator can still perform one screen over costs a redirect; a silent desync costs a coach's bookable inventory with nothing on any screen to explain it.
A partial unique index on (booking_id, provider) where provider IS NOT NULL and fulfilment_status is one of pending/scheduled/recording/ready. Both callers are made to fail gracefully into it rather than each carrying its own copy of the rule: createAddOns skips a camera the booking already has and the route answers 409, and or_create_booking_add_on raises a named add_on_already_on_booking that the protocol maps to 409 with a sentence. 'failed' and 'cancelled' are outside the index on purpose — a camera that refused is holding nothing, so a second attempt must stay possible. 'ready' is inside it: the device is free but the recording was sold, and selling it twice for one session is the double charge this exists to stop.
Alternatives considered
The physical fact is one camera per resource, so the invariant is about hardware, not about who typed it in — which is why it belongs on the table both writers share rather than in either writer. Putting it in the index also makes the failure honest: the second sale cannot half-succeed and leave a charged customer with no recording. The excluded statuses are what keep the guard from turning into its own outage: it only ever refuses when a working recording genuinely exists.
Match the RPC, not the surrounding convention. The new lessons query filters on coach_id + status <> 'cancelled' + half-open overlap and nothing else — no court filter, no facility filter — which is byte-for-byte the guard `hold_lesson_slot` runs before it looks at courts. The invariant being bought is one-directional: a slot this endpoint calls 'available' must never be one the RPC rejects. Any predicate NARROWER than the RPC's re-opens the exact class of bug being fixed, just in a rarer case. The pass is also deliberately expiry-blind for the same reason (the RPC ignores hold expiry, so an abandoned checkout keeps the hour blocked on every reserved court until the cleanup cron sweeps, ~1 min) — the handler carries a load-bearing comment saying that window must only ever be narrowed on BOTH sides together. Slot status became the max of the two passes (available < held < booked) rather than last-write-wins, so the passes are order-independent and an in-flight lesson hold reports 'held' on parallel courts, matching what the court pass reports on the lesson's own.
Alternatives considered
Availability and the hold are two readings of one question and the hold is the authority — so the read side's predicate should be a copy of the write side's, not an independently-derived approximation that happens to agree today. Deriving it independently is how the original bug happened (the admin twin at lessons/available already had the coachBusy check; only OpenRally diverged). Copying it makes the agreement structural instead of coincidental, and makes any future narrowing of one side an obvious, greppable break of the other.
Extracted the effective-price formula (getLineItemTotal's exact logic) into line-item-pricing.ts in each app so display and any future persistence math share one implementation. pos_order_items.total_cents and unit_price_cents are UNCHANGED — still gross, pre-item-discount — because every existing reader already assumes that. Only two things changed: pos_orders.discount_total_cents now includes the sum of item-level discounts alongside the existing order-level (cashier/resolver) discount, and pos_order_discounts (schema already had order_item_id, never populated) gets one row per discounted line, attributed via order_item_id. total_cents = subtotal - discount_total_cents was already correct once discount_total_cents was fixed. A second /code-review pass caught a further consequence: apps/terminal's RefundModal computed a single order-wide discountFactor = (subtotal - discountCents) / subtotal applied uniformly to every item's gross total — correct when discountCents was order-level-only, but wrong now that it can include item-specific amounts (an order with one discounted and one undiscounted item would over-refund one and under-refund the other). Fixed by having RefundModal read pos_order_discounts directly: item-tagged rows subtract from that specific item first, then the remaining (untagged, order-level) discount spreads proportionally across what's left — item-specific discounts apply exactly, order-level discounts still spread as before.
Alternatives considered
total_cents' gross/net meaning is a shared contract with readers outside the write path that produces it — changing it needs an audit of every reader, not just the mutation being fixed. Keeping it gross and only touching the two things that actually needed to change (discount_total_cents, pos_order_discounts) fixes the reported bug with a much smaller, safer blast radius.
A trigger on bookings (AFTER UPDATE, status → 'cancelled') marks every live add-on on that booking 'cancelled' and leaves external_ref in place, which is exactly what releaseCancelledRecordings() looks for. Rows in 'ready' are untouched: the session happened and the customer has the video. The provider is NOT called from the trigger — that is an HTTP call and has no place in a user's transaction — and bookings.price_amount is not rewritten, because a cancelled booking's total is history. Automatic release is then scoped to origin='setpoint': whoever booked the device releases it, and a consumer-booked recording is released by the consumer's own cancel path. A consumer-owned row still holding a device is surfaced on the booking with an explicit 'Release camera' action rather than quietly cleaned up by us.
Alternatives considered
Cancelling is the one lifecycle step several systems can initiate, so it is the step where a rule kept in application code is guaranteed to be missed by somebody. Putting it on bookings means every path present and future inherits it for free, while ownership of the provider call stays exactly where scheduling put it. Surfacing the leftover case instead of auto-fixing it follows DEC-304: reaching across an ownership boundary is a decision, and a decision should have a person behind it and a visible result.
Additive protocol surface at 0.5.0: GET /facilities/{id}/add-ons (catalogue + available_resources, so a consumer never offers a camera on a court without one), GET/POST /bookings/{id}/add-ons, DELETE /bookings/{id}/add-ons/{id}, add_ons[] on the single-booking read, and four events — booking_addon_added | cancelled | ready | failed — emitted from a trigger into or_events so the existing fan-out and delivery worker carry them with no new endpoint. Adding and cancelling go through two Setpoint functions (or_create_booking_add_on / or_cancel_booking_add_on) because the protocol server is a stateless translator that never authors booking rows. The provider is NEVER called inside a consumer request: the row lands as status='pending' and the admin reconcile cron books the device, releases devices for cancelled rows (which keep external_ref precisely so it can), and polls results. Scheduling ownership is 'the PMS that owns the courts owns the recording' — for Setpoint facilities that is Setpoint, and the consumer mirrors for display only.
Alternatives considered
Additive-only keeps every existing consumer working untouched (no add_ons sent = old behaviour). Routing writes through Setpoint functions preserves the rule that OpenRally stores nothing and authors nothing, and moving all provider I/O into one cron means neither a slow camera nor a dead one can affect a booking request on either side.
Two tables: facility_add_ons (catalogue an operator edits) and booking_add_ons (what is actually on a booking, with name/price/currency/time snapshotted). The add-on price is added to bookings.price_amount so every existing money path — checkout, payments, reports — keeps working with no changes, while the breakdown lives on the add-on row. A Setpoint booking is one court over one time range, so one add-on row per booking is one recording; a multi-court booking is sibling bookings and each sibling gets its own independently. Devices are booked AFTER the booking is written and never inside its success path: a provider that is down leaves fulfilment_status='failed' with a readable failure_reason on a booking that still exists. Cancelling a booking releases the device; editing its time, court or duration cancels and re-books it. Removing one add-on cancels just that recording and subtracts its price.
Alternatives considered
The operator's mental model is 'this booking includes a recording', so the add-on row hangs off the booking and freezes its own price; the catalogue stays free to change. Treating the provider as best-effort keeps a camera outage from ever costing a court reservation, and makes the failure visible where the operator is already looking instead of only in an alert channel.
New `facility_integrations` (facility_id, provider, base_url, auth_username, auth_secret_name, config, is_active; unique per facility+provider), owner/admin-only including SELECT. The password is NOT a column: `set_facility_integration_secret` writes it to Vault and the row keeps only the secret's name; `get_facility_integration_secret` reads it back. Both are SECURITY DEFINER and service-role-only, with the calling admin route enforcing facility access; an AFTER DELETE trigger drops the Vault secret with the row. Resolution order is facility row (active, with username + secret) -> env fallback -> ProviderNotConfiguredError, so the club running today keeps working with no data entry. Provider adapters became per-connection factories with the login-token cache keyed by account, so two facilities can never share a token. Settings → Integrations gains a Recording Provider card with a write-only password field and a Test connection button.
Alternatives considered
The connection is a property of the club, so it belongs on a facility-scoped row that owners can manage themselves — the same instinct as the consumer app's per-club-per-platform table, minus the part of that model which cannot scale (shared credentials) and minus plaintext secrets. Keeping env as a fallback rather than a migration target means the live club needs no cutover, and a second club is now pure data entry, not a deploy.
v1 is read-only and provider-direct: Setpoint stores only the court→device mapping (`court_cameras`, owner/admin write, staff read) and an admin-side service (`apps/admin/src/lib/cameras`, one adapter per provider) calls the camera API itself on page view. Status is never stored — `GET /api/admin/facilities/[facilityId]/camera-status` fetches live, in parallel, 5s timeout per device, behind a 60s per-facility in-process cache with `?refresh=1` to bypass. Statuses normalise to available | unavailable | unknown, where unknown ('we could not ask') is deliberately distinct from unavailable ('the camera answered and is down'). Nothing is written back to the consumer, no pricing/catalogue/selling moves, and the consumer's camera pipeline is untouched.
Alternatives considered
The operator need is 'is the camera on this court working right now', which is exactly one live read; everything else in the feature can stay where it already works. Keeping the adapter behind a CameraProvider interface means a second vendor is a new file, not a schema change, and keeping the mapping (not the status) as the only persisted state means there is no row that can silently drift from reality.
Hybrid list-price in calculate_member_price (signature unchanged). If the booking interval has no internal window boundaries from matching rules, keep DEC-273 exact duration match. If it spans 2+ windows, split at those boundaries and price each segment as ROUND(overlap_minutes/60 × winning 60-min row price) with the same court/sport/dow/priority cascade per segment start. Partial hours (e.g. 10:00–11:30 → 800+250=1050) are included. Fail-closed to 0 if any segment has no 60-min rule. Member hard override when spanning sums prorated 60-min member amounts only when every segment has member_price_amount; otherwise resolve_discounts runs on the summed list total. One RPC fix covers admin, web, OpenRally, and TPS.
Alternatives considered
Clubs set peak/off-peak as abutting windows and mentally price by the clock; the start-window exact-duration shortcut silently mispriced every multi-window booking. Limiting read-time proration to the spanning case preserves fixed-mode in-window prices while matching operator expectation (confirmed with Hua Hin cases including 90-min remnants).
Unify all three apps to OTP-only, invite-only login (shouldCreateUser: false), porting Admin's exact two-step email→code flow into both apps' own visual design (not a wholesale copy — each app already has its own login page styling and its own createClient() singleton wrapper, both preserved). Self-serve register pages and forgot-password pages in both apps are closed (redirect to /login), mirroring how Admin's auth/signup was gutted. Includes Terminal, per Alba's explicit call — full consistency won over the shared-device friction concern.
Alternatives considered
Confirmed as a pure UI-level cutover: verifyOtp() never reads encrypted_password, so existing POS/Terminal users' passwords are left untouched and unused — no backend migration, no account invalidation, no risk of locking out staff at already-operating facilities. Full trail (research + AC + test plan) on Linear ALB-27.
Admin's /auth/login was rewritten to a two-step OTP flow (signInWithOtp with shouldCreateUser: false, then verifyOtp) and /auth/signup was gutted to redirect to /auth/login — no more self-serve admin account creation. Existing users' encrypted_password was left untouched (not cleared), since Supabase Auth supports password and OTP as independent, simultaneous grant types per user — this was a UI-level cutover, not a backend migration.
Alternatives considered
Security: invite-only OTP means no open self-serve signup surface and no passwords to leak/reuse/phish. Directly reverses DEC-056's 'keep email + password, already working' call from three months earlier — worth its own record specifically because it silently invalidated that entry without cross-referencing it, which is the kind of docs-sync gap this repo's own .claude/rules/docs-sync.md exists to prevent. CLAUDE.md's Architecture Decisions line was also stale on this until ALB-27 corrected it.
Guest bookings deliberately create ZERO customer/CRM footprint — user_id stays null, the typed label goes straight into bookings.guest_first_name, and the players[]/resolveOrCreatePlayerAccount path is never touched. This is a genuinely separate, explicit toggle in create-booking-dialog.tsx (guestBookingMode), not inferred from "player with no email," specifically so it can never be confused with (or accidentally regress) the walk-in-account pathway find-or-create-user.ts protects. Implemented with zero backend or schema changes — bookings.user_id was already nullable, guest_first_name/guest_last_name already existed and were already handled correctly end-to-end (calendar rendering, search, RLS; supabase/migrations/20260520100304_add_identity_to_booking_created_payload.sql's own comment — 'If both are null (anonymous walk-in) the field is null and the consumer skips — correct' — confirms a null-user_id, guest-named booking was already an anticipated state for the OpenRally booking-created event) — the feature was blocked purely by the dialog always requiring at least one player before allowing submit.
Alternatives considered
Guest bookings will never appear in CRM customer search, purchase history, or repeat-visit detection — logging this explicitly so a future dev doesn't read it as a bug and 'fix' it back to creating accounts, which would defeat the feature's purpose. v1 intentionally reuses the existing booking dialog (incl. recurring, court selection) rather than a new lightweight flow, and renders identically to a real booking on the calendar — the typed name is the only differentiator, per Alba's explicit choice.
New route apps/admin/src/app/api/admin/packages/[id]/payment-link/route.ts (+ /email sibling + send-lesson-package-payment-link edge function), built on the Stripe Checkout Sessions API (mode: 'payment', inline price_data) — the exact pattern already used by the bookings/lessons/memberships admin payment-link routes and the reusable PaymentLinkActions UI component, NOT the PaymentIntent pattern the existing purchase route uses. A new webhook branch (metadata.type = 'lesson_package_payment') creates the player_packages + player_package_sessions rows on checkout.session.completed, mirroring confirm-purchase's writes exactly (status: 'active', stripe_payment_intent_id, expires_at from valid_days). v1 restricted to session_bank packages only (route 400s on package_type='recurring') — recurring packages already have a subscribe flow and extending payment links to them raises the same billing-period rollover question flagged in ALB-7, deferred until that's answered.
Alternatives considered
Every other entity with an admin payment-link (bookings, lessons, memberships) already uses Checkout Sessions + PaymentLinkActions — reusing it here keeps packages consistent with the rest of the app instead of introducing a second payment-link mechanism. Per CLAUDE.md 'built-in first,' Stripe Checkout's hosted page is used instead of a custom one.
Added 'any' as a third explicit literal to package_items.class_type (CHECK widened to ARRAY['private','group','any']), following the exact precedent already set by the discount engine's scope_domain column (ARRAY['booking','lesson','program','pos','any']). consume_package_session() gained a third matching tier: exact class_id match, then class_type = the lesson's concrete type, then class_type = 'any' as the last-resort fallback — an 'any' bucket only drains after a more specific bucket of the correct type is exhausted, so specific-type packages aren't silently starved by a wildcard one.
Alternatives considered
The discount engine already solved this exact problem (an enum column needing an explicit 'unrestricted' member without disturbing NULL's existing meaning) — reusing that convention keeps 'any' meaning the same thing everywhere in the schema instead of introducing a second idiom. v1 scoped to session_bank packages only per the ALB-25 ticket; recurring packages are deferred pending ALB-7's audit of billing-period session rollover.
(1) Replaced ai-feedback-auth.ts entirely — GET /roster and PATCH /ai-feedback now authenticate via the existing hashed, per-facility api_keys system (validateApiKey from api/v1/_lib/api-key.ts, previously-unused infrastructure) and verify the resolved facility_id matches the lesson's. One real api_keys row per club, same as any other third-party integration. (2) emit_group_lesson_player_event now holds back session_feedback_updated while feedback_generated_by='ai' AND feedback_reviewed_at IS NULL; the transition of feedback_reviewed_at from null to set now also triggers emission, so reviewed feedback still reaches OpenRally/TPS, just at review time instead of draft time. (3)+(4) Moved the overwrite-protection + write into one atomic SQL function (apply_ai_feedback_to_lesson_player, SETOF return) with the protection condition inside the UPDATE...WHERE clause itself (re-evaluated against current row state, closing the race) and extended to cover coach_rating, not just coach_feedback text.
Alternatives considered
Per CLAUDE.md 'root-cause fix (schema/trigger layer), not a UI hack' — all three correctness issues are now enforced where no future caller can bypass them, not just in the two routes this PR added. Caught by an 8-angle/10-candidate code review with independent verification before merge, per the mandatory /code-review gate — nothing here reached production.
Extended the middleware's existing external-auth allowlist (previously a single literal path for the Resend webhook, renamed EXTERNAL_AUTH_PATHS) with a second, pattern-based list (EXTERNAL_AUTH_PATH_PATTERNS, regex) to match the dynamic /lessons/:id/ segment, and skip the session check for both.
Alternatives considered
Reuses the exact pattern already established for the webhook route rather than inventing a new bypass mechanism. The regex list is additive and narrowly scoped to these two paths — no change to session enforcement for any other route. Caught by e2e test TC-253 (the routes 401'd even with a correct API key until this fix landed).
A row is finalized (shown) when feedback_generated_by='coach' (any coach-written row, regardless of feedback_reviewed_at) OR feedback_generated_by='ai' AND feedback_reviewed_at IS NOT NULL. Unreviewed AI drafts never surface here.
Alternatives considered
A human typing feedback directly has no unreviewed-draft risk — requiring a review timestamp for coach-authored rows would be busywork with no safety benefit. AI drafts are the only case where an operator hasn't necessarily seen the text yet, so those specifically gate on the Review & Save step (feedback_reviewed_at) before appearing anywhere player-facing.
Yes. PATCH /api/admin/lessons/[id]/players sets feedback_generated_by='coach' whenever coach_feedback or coach_rating is included in the request body (attendance-only edits don't flip it).
Alternatives considered
The PATCH /ai-feedback ingestion endpoint's overwrite protection is keyed off feedback_generated_by='coach' + non-empty coach_feedback (see ALB-5 spec). If a coach's edit to an AI draft didn't flip ownership, a later AI agent re-run could silently clobber the coach's correction — defeating the whole point of the protection. Flipping on edit means 'coach has touched this' always wins going forward.
Second screen inside the same Dialog, via internal step state ('roster' | 'review'), matching the existing convention in merge-customer-dialog.tsx (search → confirm → resolve → done).
Alternatives considered
merge-customer-dialog.tsx already establishes this exact pattern elsewhere in the admin app — one Dialog, internal step state swapping content in place. Reusing it means zero new UI components/patterns for a coach to learn, and less code than a second modal + open/close orchestration.
Model a paid join/enroll as hold-seat → pay → confirm, and choose RECORD-ONLY against Stripe TEST keys as the launch semantics. Migration 20260605094626 (all additive): (1) match_players gains a TTL'd 'held' join_status + hold_expires_at; (2) the update_match_player_count trigger now counts join_status IN ('joined','held') so a held seat reserves capacity (everything else — results, elo, RLS — still keys off 'joined' and is unchanged); (3) program_enrollments gains a real 'pending_payment' status (stops overloading 'waitlisted') + a partial-unique index; (4) hold_match_seat RPC locks the match row FOR UPDATE, re-counts, and inserts a 'held' seat (or 'joined' for free/pay-at-club) — fixing the last-seat race and raising match_full/already_in_match; (5) confirm_match_seat + confirm_program_enrollment RPCs flip held→joined / pending_payment→enrolled idempotently (program one also writes the payments ledger row once per PaymentIntent); (6) expire_match_holds + a per-minute pg_cron sweep free abandoned holds. Routes: matches/[id]/join inserts a held seat then creates the PaymentIntent (metadata type=match_join, match_player_id) and returns client_secret — never marking paid; the Stripe webhook payment_intent.succeeded gains match_join + program_enrollment cases that call the confirm RPCs; programs/[id]/enroll uses 'pending_payment'; confirm-enrollment routes through the same shared idempotent RPC. The same flow flips to real card charges later by swapping Stripe to live keys — no code change.
Alternatives considered
Setpoint runs Stripe in TEST mode in prod by design (no real cards charged), so record-only against test keys exercises the EXACT PaymentIntent → webhook → confirm path we're fixing with zero real money and a clean one-switch path to live charges — and stays decoupled from the live-keys cutover. hold-seat → pay → confirm means a seat only counts as paid AFTER the card clears; the held seat still reserves capacity (so the last seat can't be double-sold) and is swept free if abandoned; confirm is idempotent so a webhook retry is a no-op. Validated locally on a fresh `supabase db reset` (migration applies cleanly), a rolled-back transaction RPC behavior suite passed 7/7 (held reserves capacity; second hold fills + status=full; third join → match_full; duplicate → already_in_match; webhook confirm flips held→joined and stamps paid_amount; confirm idempotent; expired hold → cancelled + match reopened), regenerated database.types.ts, and `tsc --noEmit` on apps/admin clean (0 errors). All migration changes are additive/reversible (new enum values, a nullable column, partial indexes, replaced functions, new RPCs, one cron) — no existing data rewritten. NOT in scope (deferred, by design): un-hiding the admin nav, the OpenRally session_* publish, and the TPS consumer wiring — these are the next steps. Scoped to the isolated worktree off latest main; nothing merged or deployed pending CEO review.
Make `availabilityWindowsForDate` the single source of truth for recurrence matching + per-court exception subtraction, and propagate DEC-237 court scoping to all 8 affected surfaces. (1) 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 ⇒ apply every exception (pre-v0.30.0 back-compat for callers that don't track the court). (2) apps/admin/.../calendar-grid.tsx (day view, the reported surface): exceptions SELECT adds court_id; exceptions are filtered to (ex.court_id == null || ex.court_id === r.court_id) BEFORE both the whole-day check AND subtractTimeRanges. (3) .../coaches/[id]/available-slots/route.ts: REPLACED its hand-rolled per-row subtraction with `availabilityWindowsForDate` grouped per court (court_id added to the exceptions SELECT); removed the now-unused parseHM. (4) .../lessons/available/route.ts: REPLACED hand-rolled subtraction with `availabilityWindowsForDate` per (coach, court); court_id added to the SELECT + exceptionsByCoach; removed the now-unused toMinutes; whole-day handling is now per-court (was a single global coachIsOff set). (5,6,7) .../admin/lessons/route.ts (POST), .../admin/lessons/[id]/route.ts (PATCH), and .../player-packages/_lib/generate-lessons.ts: exceptions SELECT adds court_id, and each passes the lesson's courts into `validateLessonAgainstAvailability` (POST: court_ids; PATCH: courtIds, undefined on time-only edits ⇒ legacy all-exceptions; generate: sched.court_ids || [defaultCourtId]). (8) .../v1/facilities/[slug]/coaches/[id]/route.ts: added court_id to the public exceptions projection (a contract gap — DEC-237-aware consumers can now scope). The known-correct reference twin already doing per-court scoping is availability/route.ts (per-court loop with courtId); the other surfaces now match it.
Alternatives considered
Architecture-first, single-source-of-truth + reuse-don't-reinvent (per CLAUDE.md no-debt): the four read surfaces and the three lesson write-gates now all flow through `availabilityWindowsForDate` with DEC-237 court scoping, the same algebra the availability/route.ts twin and the in-prod helpers already use, so the day calendar finally stops advertising a coach-held court as bookable and the write-gate stops surprising reception (BUG-104). Verified locally: a Node type-strip harness over the real edited lib passed 10/10, including the exact bug (court A keeps 10:00–12:00 despite a court-B time-off), all-courts NULL still applies to every court, whole-day exceptions scoped per court, same-court still correctly rejects, and a back-compat guard (no courtId ⇒ legacy all-exceptions); plus 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 clean, no dangling/unused symbols (grep-confirmed parseHM/toMinutes removed). NOT verifiable locally: route-level integration (needs a running Next server, which was off-limits during this fix). NOT yet deployed (pending PR merge) — but the postcss build-worm that blocked local builds while this was written was already stripped from all app configs in #716 (now on main, with a pre-commit re-infection guard), so the normal merge → Vercel deploy path is clean and safe; the Node harness was used because builds were blocked AT fix time, not because anything is still infected. (env.local.example + protocol/server/env.example show modified in the tree but are unrelated other work and are excluded from this commit.)
Replace the single-exception @> containment test with subtract-then-overlap, computed PER COACH. For each coach with a strict (blocks_courts=true) availability applicable on the date on this court, compute (range_agg(time_range) MINUS range_agg(that coach's date+court-scoped windowed exceptions)); drop a coach that has a whole-day exception (start_time IS NULL) for the scope; BLOCK iff ANY coach's surviving multirange OVERLAPS (&&) the booking. Mirrors the lesson_court branch and coach_blocked_intervals_for_facility, keeps the blocks_courts=true filter (DEC-245), the per-court predicate (ae.court_id IS NULL OR ae.court_id = p_court_id, DEC-237), the whole-day-zeroes-the-day rule, and the same court_reserved_for_coach RAISE/DETAIL. Shipped as one additive, reversible CREATE OR REPLACE migration (20260604100847_fix_court_booking_gate_exception_subtraction.sql) that changes ONLY the court_booking branch — signature, DECLAREs, tz/midnight/seconds setup, the DEC-247/DEC-248 lesson_court branch, and the triggers are byte-identical to 20260527141545. Regression coverage: a 24-case SQL matrix run against the locally-applied function (incident allowed; 12:00–14:00 still blocked; split-window; whole-day; open-mode; control) plus a 4-case lesson_court no-regression check, and E2E tc-250 (incident booking succeeds via /api/admin/bookings, remaining-hold still 400s, cross-coach orphan-safety still 400s, calendar shows the freed hours bookable).
Alternatives considered
Architecture-first, single-source-of-truth: the court_booking write-gate now uses the same multirange algebra as the four paths that were already correct, so the calendar/operator view, the public + OpenRally availability surfaces, and the write-gate finally agree (closing a Setpoint↔TPS read/write divergence — the read side already advertised the slot bookable). Per-coach subtraction is strictly more correct than the old @> AND orphan-safe, where a naive aggregate would not be. Verified LOCALLY only (supabase db reset + 28 passing SQL assertions + lesson_court no-regression); NOT yet deployed. Deploy is the approved one-shot: supabase db query --linked --file supabase/migrations/20260604100847_fix_court_booking_gate_exception_subtraction.sql (NEW file). Immediate operator workaround pre-deploy: book the two sub-slots (10:00–11:00 then 11:00–12:00), or widen the coach time-off to start at the booking start so the legacy @> passes. Supersedes the DEC-256 '@> hardening' follow-up. Full investigation trail: coach-exception-interval-fix-plan.md (S5).
(1) Reuse player_ratings. Add a single generic level_type='consumer' to the CHECK (additive); the provider identity lives in the existing `source` column (a consumer namespace supplied by the caller, e.g. or_providers.namespace) — never a hardcoded vendor name. (2) New SECURITY DEFINER RPC `or_upsert_player_level(email, facility_id, sport, level, level_type, source)` email-matches a customer (reusing the ExternalBookingCreationService pattern) and upserts READ-ONLY on the EXISTING UNIQUE(user_id,facility_id,sport_type,level_type); it RAISEs user_not_found and blocks internal level_types from consumer ingest. (3) New additive OpenRally endpoint `POST /openrally/v1/players/by-email/level-ingest` (version 0.1.0→0.2.0, idempotent, API-key auth) — facility comes from the authenticated key, never the body. (4) Club config in facilities.settings.enabled_level_systems (per-sport list); admin Leveling Systems settings tab; GET ratings filters external systems by it (internal self_assessed/admin_override/coach_set always shown). (5) Admin customer page shows external levels READ-ONLY (lock icon); no admin edit, no migration. (6) The literal 'tps' appears ONLY in padel-backend (consumer side). (7) The consumer's push runs in the BACKGROUND (padel-backend, via EdgeRuntime.waitUntil), off the match-scoring path, so a slow/down OpenRally never delays or fails match certification. POST-LAUNCH the push was broadened beyond match certification to ALL TPS level sources: onboarding + manual edits are caught by a 5-min pg_cron reading historical_leveling (padel-backend #232), and a daily reconciliation cron (padel-backend #235; rate-limit pacing fixes #236/#237) re-pushes current levels in rolling id%144 slices every 10 min so newly-matched customers with static levels and any drift self-heal; existing customers were seeded once by an email-matched backfill (4,018 at Pad Thai, run via the same RPC). Fixed two adjacent latent bugs surfaced by the feature: the ratings API slug/id param mismatch and the facilities PATCH that clobbered settings (now merges); plus an orphaned facility_customers role-trigger that broke user_facility_roles inserts + db reset.
Alternatives considered
Smallest additive, reversible change that satisfies every CEO constraint and the consumer-agnostic principle: one new enum value + one RPC + one additive OpenRally endpoint + a jsonb config + read-only admin display, with TPS-specific code confined to padel-backend. Verified locally: migration applied + RPC psql-tested (match/upsert/idempotent/unknown-email/blocked-internal-type); OpenRally `cargo test` green (full suite except a pre-existing unrelated or_sports seed flake) plus a new 7-case integration test; admin app `tsc --noEmit` 0 errors; DB types regenerated. DUPR/WPR live API sync is a later PR (needs partner API access); their toggles are present now. padel-backend push is a small companion PR in that repo. Full exploration + debate captured during build.
Make blocks_courts faithful end-to-end and fail loud on a missing flag rather than defaulting (CLAUDE.md 'No silent errors'). (1) `coach_schedule_replace` removes the `COALESCE(...,true)` default and RAISEs if any availability element lacks an explicit boolean blocks_courts; it also takes a per-(facility,court) `pg_advisory_xact_lock` to serialize concurrent same-court replaces. (2) The admin route `apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts` stops doing `blocks_courts ?? true`, returns 422 on a missing/non-boolean flag, and calls a new pre-save check. (3) New STABLE function `coach_availability_overlaps(p_coach_id, p_facility_id, p_availabilities)` enumerates strict×strict same-court overlaps using the IDENTICAL predicate as the `coach_availabilities_check_overlap` trigger (half-open numrange `&&`, symmetric recurrence-date intersection via `coach_availability_dates` / `coach_availability_applies_on_date`, both blocks_courts=true, including intra-payload sibling pairs and excluding the edited coach's own existing rows). The route surfaces the result as a 409 with an actionable `overlaps[]` list instead of an opaque DB raise. The overlap TRIGGER itself is unchanged (it is the correct DEC-254 relaxed version). New e2e `apps/admin/e2e/tc-248-open-window-saves-over-existing-strict.e2e.ts`. One additive migration `20260530074244_coach_overlap_consistency.sql` (CREATE OR REPLACE only, no data mutation, no DROP). Survived a 3-round adversarial debate; matches industry single-unit-resource semantics (Acuity qty-1 resource, Playtomic open-match-doesn't-hold-court, Matchi single-unit court).
Alternatives considered
Root-cause fix at the schema/trigger layer, not a UI patch: the flag now travels faithfully and any write path that omits it fails loud. The pre-check mirrors the existing `coach_availability_booking_conflicts` pattern and shares the trigger's exact predicate, so the operator gets an actionable overlap list AND the two can't drift (predicate-parity). Verified locally before merge: open+strict same-court saves (the Jose/Zeus shape), strict×strict same-recurrence-date is still rejected, missing-flag payloads error loudly, and non-coinciding strict pairs are never falsely stranded. Direct-insert test fixtures and seed.sql bypass the RPC (they use the column default) so they're unaffected. Deploy: the admin route auto-deploys on merge; the migration is a manual `db query --linked --file` step. Full spec: coach-overlap-fix-plan.md.
Strategy A0: add ONE shared STABLE SQL helper `coach_lesson_windows_for_date(p_facility_id uuid, p_coach_id uuid, p_date date)` returning surviving local-time sub-windows `(court_id, start_time, end_time) = range_agg(availability) − range_agg(exceptions)` via Postgres multirange subtraction, per-coach, with per-court exception scoping `(ae.court_id IS NULL OR ae.court_id = a.av_court_id)`, whole-day exceptions (start_time NULL) zeroing the day, half-open `[)` semantics so a boundary touch does not overlap, and — per DEC-247 — NO `blocks_courts` filter (lesson eligibility ≠ court hold). It is the lesson-eligibility twin of the proven in-prod `coach_blocked_intervals_for_facility`. Both buggy Rust sites now call it: the `coach_availability.rs` handler `SELECT … FROM coach_lesson_windows_for_date(...)` (the `NOT EXISTS` deleted; the slot-slicing loop unchanged), and `coach_search.rs` `has_availability = EXISTS(SELECT 1 FROM coach_lesson_windows_for_date(...))`. Dead `list_availabilities` / `list_availability_exceptions` helpers (which carried the same buggy pattern) and their unused structs were removed. New migration `20260601043321_coach_lesson_windows_for_date.sql`; 12 cargo tests over the real HTTP endpoints for both sites (split, whole-day, boundary-touch, window-split, adjacent-cover, per-court DEC-237) asserting parity vs Setpoint's `availabilityWindowsForDate` and the blocked-intervals helper. Response SHAPE is unchanged (it just returns the slots it was wrongly dropping) so no protocol version bump.
Alternatives considered
Architecture-first, reuse-don't-reinvent: stop re-implementing exception math in raw SQL inside Rust and instead use the same multirange algebra the four correct Setpoint paths already use, behind a single helper that mirrors `coach_blocked_intervals_for_facility`. The change only touches the read side, makes OpenRally STOP under-reporting availability, and cannot create double-bookings (the write-time trigger remains the final arbiter and already subtracts correctly). After it lands, the OpenRally handler, discovery search, materializer, the blocked-intervals helper, and the lesson write-gate all agree. Already deployed to prod (2026-06-01) as a hotfix; PR #697 is the record + review. Follow-ups (separate PRs): the `@>` court-booking gate `range_agg` hardening (+ its twin) and consolidating the admin-TS subtraction copies onto `availabilityWindowsForDate`. Full spec: coach-exception-interval-fix-plan.md.
OpenRally lesson-hold endpoint (setpoint protocol/server) gains a hold_duration_minutes request field that the consumer supplies; the server uses it as the hold TTL when present and falls back to a default. The default was raised 5→15 min so even a consumer that doesn't send the field gets a window that comfortably covers a typical payment flow. The protocol stays consumer-agnostic — it exposes a duration knob, it does not encode TPS's 10-minute number. The consumer (padel-backend) sends 15 (= its 10-min payment window + a 5-min buffer) so the hold always outlives the payment window. Shipped as setpoint OpenRally server PR #688 (merged, flyctl-deployed). Verified live 2026-05-28 (coaching booking #60839, Sandbox Pad Thai Padel / club 625, court "The Padel Society" / 1273): paying slowly through the full window still completed and the court was not taken mid-payment.
Alternatives considered
Matches the slot-duration principle already in this codebase: the consumer requests the shape it needs and the server validates/honors it, rather than the protocol hardcoding a consumer assumption (see DEC-112). The default bump (5→15) is the no-silent-errors backstop — a consumer that omits the field still gets a window long enough for a normal payment flow instead of a hold that quietly expires. The buffer (consumer sends payment-window + 5) means clock skew and a slow final confirm can't lose the court. Paired with BUG-097 (DEC-241) + the court-passthrough/hold-buffer fix (DEC-243); together they make a TPS-originated coaching booking land on the right court and stay held for the full payment.
Two coordinated changes in padel-backend (PR #221). (1) Court passthrough: the court the picker displayed is carried through to the OpenRally hold, so the hold reserves THAT court — not a fresh "first available" pick that could differ from what the player saw. (2) Hold buffer: the OpenRally hold is requested with a TTL that exceeds the TPS payment window — a 15-minute hold over the 10-minute payment window (a 5-minute buffer) — via the hold_duration_minutes field added on the server (DEC-244). Verified live 2026-05-28 (booking #60839): the hold landed on the displayed court "The Padel Society" / 1273 and survived the full payment window.
Alternatives considered
Native-equivalence rule (CLAUDE.md): the result of a TPS-originated coaching booking must look like a natively created Setpoint lesson — on the court the player was shown, not a fallback guess. Court passthrough closes the display↔hold gap; the hold buffer closes the timing gap. Both are consumer-side (padel-backend) corrections that consume the now-correct OpenRally availability (BUG-097 / DEC-241) and the server's hold-duration knob (DEC-244). Paired with the pricing correction (DEC-242).
Coaching pricing now comes from the coach's class price — classes.price_per_pax_amount — which is all-inclusive of the court, matching how Setpoint prices a native lesson. It no longer uses the court rental price. Shipped as padel-backend PR #220. Verified live 2026-05-28: THB 3,000 (the coach's class price) was charged, not the court rental rate.
Alternatives considered
Native-equivalence (CLAUDE.md): a coaching lesson booked via TPS must cost what the same lesson costs when an operator books it in Setpoint admin — the coach's class price (price_per_pax_amount), inclusive of the court. Reading the court rental price was a category error: court bookings are priced from the pricing cascade, lessons are priced from the class. The fix routes coaching to the class price so both origins agree. Part of the 2026-05-28 TPS↔OpenRally coaching batch alongside DEC-241 / DEC-243 / DEC-244.
The OpenRally branch of get-coaching-availability-v2 now sources both the reserved-court set and the court combinations from OpenRally instead of TPS's local booked_slots. (1) reservedCourtIds are derived from OpenRally (padel-backend PR #218) — the picker reads what OpenRally reports as reserved rather than inferring from stale local slot rows. (2) court combos are built from the coach's OpenRally availability (padel-backend PR #219) — so the picker offers the coach's actual court(s), reading coach_availabilities + availability_exceptions correctly through the protocol. Result: the picker shows the correct coach AND the correct court. Shipped + verified live 2026-05-28 (booking #60839, Sandbox Pad Thai Padel / club 625, court "The Padel Society" / 1273).
Alternatives considered
This is the consumer-side counterpart to DEC-240: the protocol now reports coach availability honestly (materializer subtracts it; coaching availability endpoint reads coach_availabilities + exceptions), so the TPS picker should consume that truth rather than re-deriving it from a stale local table. Reading reservedCourtIds and court combos from OpenRally makes the picker show the real coach and court, which is the precondition for the hold landing on the displayed court (DEC-243) and for correct pricing (DEC-242). Originally tracked as BUG-097 (DEFERRED) — now SHIPPED/RESOLVED 2026-05-28 (padel-backend PRs #218 + #219), verified end-to-end with a real coaching booking (#60839).
No. Open-mode coach slots return as plain 'available' to the protocol. No coach_floating / open_mode / bookable_by_coach field. Consumers see one truth: 'court X at time T is bookable.' Whether a coach also has open availability there is the producer's internal concern; if a future consumer needs to render 'coach available here', they query /coach-availability separately (that surface already exists).
Alternatives considered
The protocol carries facts, not opinions. The fact is 'this slot is bookable.' Whether a coach happens to have stated open availability there is irrelevant to the booking decision — if the customer takes the court, the coach loses the slot, exactly as designed (DEC-246). Honoring CLAUDE.md 'OpenRally is just a protocol — don't reinvent UI/UX on either side' keeps the wire clean and every future consumer's burden constant.
Allow when both overlapping rows have blocks_courts=false. The trigger only rejects overlap when BOTH rows have blocks_courts=true. Open+open is allowed; open+strict is allowed (strict wins the court claim); two strict still rejected (current behavior).
Alternatives considered
Under open mode the operator's declared intent is 'the court can be shared.' Two coaches sharing the same court is two operators expressing the same intent. The downstream consequence — both coaches' lessons potentially failing if a customer takes the court — surfaces via the DEC-248 enriched error, which is the right place for the trade-off to materialize. Trigger predicate becomes: (ca.blocks_courts AND NEW.blocks_courts) — three lines of SQL, no schema fork.
UI greys out per-court exception chips when the parent availability row is open, with tooltip 'Exceptions only apply in strict mode.' PUT /api/admin/coaches/[id]/schedule returns 422 (code: exception_on_open_availability) if a save attempt creates an exception whose intersect set contains only open availabilities. Route-layer validation, not a CHECK constraint, because the join is non-local.
Alternatives considered
Exception data persists (flipping the parent back to strict restores semantics) but is inert and visually inert while the parent is open. Route 422 is the loud failure for clients that bypass the UI (e.g. API consumers, future bots). UI grey + tooltip is the soft failure for humans. Both fail loud at the right altitude — CLAUDE.md 'No silent errors.'
Label: 'Open availability — court stays bookable.' UNCHECKED is strict (default). Operators opt INTO open mode by ticking the box. The DB column blocks_courts also DEFAULTS true — the polarity matches end-to-end (unchecked toggle ↔ blocks_courts=true).
Alternatives considered
Default-strict is the safe default — every existing row preserved, every new row matches current operator expectation. Polarity inversion would silently ship every new availability as open, a feature regression on day one. The schema column polarity (DEC-245) and the UI toggle polarity must match end-to-end so the next contributor reading either layer can't get the direction wrong.
Open rows still count. The toggle is about court reservation, not coach presence. An open availability means 'coach is physically here, but the court can be used by others.' For program scheduling and package eligibility, the coach IS available — the court question is downstream of 'is this coach offering hours this slot?'
Alternatives considered
Decoupling court reservation from coach presence is what makes the toggle conceptually clean. Read-side projections inherit the same separation: court-occupancy queries filter by blocks_courts; coach-availability / coach-offering queries don't. Documented in the migration comment so the next contributor reading either projection sees the rule.
Open-mode tiles render with: dashed border, reduced opacity (~60%), and a small 'Open' badge in the corner. Solid tiles = strict (current behavior). BOTH calendar-grid.tsx (day view) AND week-calendar-grid.tsx (week view) get the same treatment — no divergence. Hover tooltip on open tiles: 'Open coaching slot — court remains bookable by customers and other coaches' lessons.'
Alternatives considered
Dashed border is the universal 'soft / non-committal' affordance in calendar UIs. Reduced opacity de-emphasizes without hiding. 'Open' badge supplies the explicit label for the uncertain reader. Three cues triangulate the message; no single cue carries the load. Both grids must change in the same PR — leaving one outdated produces exactly the bug the change is meant to prevent.
Add a Postgres trigger on coach_availabilities AFTER INSERT/UPDATE/DELETE that (a) deletes affected rows from or_materialized_slots for the impacted (facility_id, court_id, [date range]) so the next read re-materializes, and (b) calls pg_notify('coach_availability_changed', facility_id::text). Add a Rust LISTEN handler in protocol/server (new file: src/cache/listen.rs, spawned from main.rs) that subscribes to coach_availability_changed and invalidates L1 cache prefix 'availability:{facility_id}:' on each notification.
Alternatives considered
The DB is the source of truth and already runs triggers for similar invariants (booking holds, payment status). Adding one more trigger + LISTEN handler is the smallest surface that achieves the freshness invariant. Native equivalence preserved: TPS sees the slot's true state within ~1 round-trip of the admin toggle. The trigger is per-row so the cost is proportional to actual writes, not to facility size.
Add a NEW guard to the lesson_court branch of check_booking_against_coach_reservations: when the matched coach_availabilities row has blocks_courts=false, additionally check that no confirmed/held booking already overlaps the requested (court_id, time window). If one does, raise lesson_displaced_by_booking with DETAIL JSON containing { displaced_by_booking_id, displaced_by_type ('court_booking' | 'lesson') }. The admin UI surfaces a human-readable explanation via translate-coach-error.ts.
Alternatives considered
The customer wins the court (open mode's defining behavior). The coach loses that hour, BUT the system communicates the loss explicitly via the enriched error — operator can then explain to the student why the lesson can't be booked. Generic lesson_outside_coach_court_window would be wrong here: the coach IS inside their availability; the problem is the court is taken. The DETAIL JSON gives the UI enough to write a human sentence ('Court was taken by a customer booking at this time — open availability does not reserve the court').
No. The lesson_court primary aggregate stays mode-agnostic — a lesson must lie within the coach's stated availability regardless of whether the row is strict or open. Open mode only relaxes the 'block others' rule, not the 'this coach's lesson must be inside their own declared time window' rule. A load-bearing SQL comment cites TC-243 as the regression test that prevents this from being 'cleaned up' later.
Alternatives considered
The two branches of the function answer two different invariants. court_booking asks 'is any other booker blocked?' — blocks_courts gates that. lesson_court asks 'is this lesson inside the coach's stated time window on the requested court?' — blocks_courts is irrelevant. Tight SQL comment + TC-243 regression test inoculate against the future 'fix for consistency' that would silently break open-mode lesson creation.
Ship ONLY the toggle. Auto-fallback is explicitly deferred to a future feature. When the coach's preferred court is taken under open mode, the coach's own lesson FAILS LOUDLY with the DEC-248 enriched error rather than auto-migrating to another court.
Alternatives considered
User instinct in the design session: 'simplest solution is the boolean toggle.' CLAUDE.md 'one feature = one branch = one PR.' Each additional invariant a PR introduces is a maintenance liability across every consumer (admin, OpenRally, TPS). Ship the toggle, observe how clubs use it, then build auto-fallback if the data justifies it. Logged as deferred follow-up in the AC file and in versions.ts when v0.31.0 lands.
blocks_courts boolean NOT NULL DEFAULT true. Predicate-style verb phrase reads as a predicate at the call site (WHERE ca.blocks_courts = true), describes the row's observable behavior directly, and future-proofs via additive booleans rather than a re-encoded enum.
Alternatives considered
An engineer reads 'WHERE ca.blocks_courts = false' and immediately understands 'this row does not block.' No domain glossary needed. Predicate-style booleans compose cleanly at the call site without an aliasing layer. Future extensibility comes from adding more booleans (blocks_lessons, warns_on_conflict) rather than re-encoding this column — each behavior gets its own self-describing name.
New SQL function coach_blocked_intervals_for_facility(p_facility_id uuid, p_start_date date, p_end_date date) returning TABLE(court_id uuid, start_time timestamptz, end_time timestamptz). Mirrors the court_booking branch of check_booking_against_coach_reservations in reverse: instead of 'is this candidate booking blocked?', returns 'what intervals are blocked?'. Uses the existing coach_availability_applies_on_date() helper for recurrence, subtracts whole-day + per-court windowed availability_exceptions via numrange/range_agg/multirange algebra (same primitives the trigger already uses), and projects facility-local seconds-of-day → UTC timestamptz via facilities.timezone so the materializer compares overlaps in its native currency. Migration: 20260527115454_or_coach_blocked_intervals_for_facility.sql. Rust: materializer fetches Vec<CoachBlockedInterval> once per pass (right after the bookings fetch) and the per-slot match becomes 'real booking wins → blocked → available' (real bookings keep their booking_id so the slot still renders as booked, not as an empty block). Status 'blocked' was already a valid value in the or_materialized_slots CHECK constraint (unused until now) so no table migration. The trigger remains the authoritative write-time guard, untouched — function exists purely so consumers stop advertising slots the trigger will reject. Materializer's existing 60s refresh cycle handles freshness; no new cache invalidation wired. Rust integration tests (tests/materializer_coach_block.rs) cover both the basic weekly-coach case and the per-court windowed-exception multirange subtraction.
Alternatives considered
The trigger and the materializer answer two halves of the same invariant: 'court X at time T is reserved for a coach.' Today, only the trigger does — read consumers fly blind and pay the cost in user-visible failures and silent-swallow downstream bugs. The new SQL function is the minimum surface that closes the read-side gap WITHOUT touching the working write-side trigger: same primitives (coach_availability_applies_on_date, range_agg, multirange subtraction), same column-scoped predicates (court_id scoping per DEC-237), same facility-timezone semantics. The materializer's existing 60s refresh cadence is already accepted freshness for booking changes — applying it to coach availability changes is the same trade-off (consumer might see a stale 'available' for up to a minute, but the trigger still rejects the hold at write time, so no double-booking can land). The proposed shared-view refactor (alternative 2) is the right long-term destination and survives its own counter-arguments, but bundling it with the immediate bug fix would unnecessarily put a working trigger at risk. Ship the closing-the-gap function now; revisit the shared view when a 4th blocker forces consolidation. BUG-097 (TPS-side slot picker reading from TPS booked_slots instead of the messenger) was the sibling read-side gap on the consumer side — now SHIPPED/RESOLVED (2026-05-28, padel-backend PRs #218–#219): the TPS coaching picker reads the correct coach AND court from OpenRally instead of the stale local table. See DEC-241 for the picker fix and known-issues.ts for the verification (booking #60839).
Add an 'Ignore this conflict' button next to each row in the red conflict panel inside the coach schedule editor (apps/admin/src/components/admin/coach-schedule-editor.tsx, the existing conflict-panel block rendered when conflicts.length > 0). One click injects a new ExceptionEntry into editor state pre-filled with court_id = conflict.court_id, date = conflict.date, start_time + end_time parsed from the conflict's time-range string. The new exception row scrolls into view; the corresponding conflict-panel row is muted/struck to indicate 'handled — re-save to clear'. Operator clicks Save Schedule a second time; the gate (now exception-aware per DEC-238) accepts. The manual 'Add exception' button at the top stays as a fallback for ad-hoc exceptions outside the conflict-resolution path.
Alternatives considered
UX realist's round-2 point in the multi-agent debate: under per-court exceptions (DEC-237) the inline button is strictly better than the same button under all-courts (Option A) because the auto-injected exception has the EXACT scope needed (court + time window from the conflict itself) — one click does what would otherwise be three or four manual steps. Combines the architect's 'data model is correct' (DEC-237) with the minimalist's round-1 'reduce operator clicks at the point of friction' (originally proposed as Option D).
Three coordinated changes in the same migration as DEC-237. (1) Reorder coach_schedule_replace (migration 20260512115124, the existing replace RPC) to DELETE-old then INSERT exceptions FIRST and INSERT availabilities SECOND in the same transaction. The per-row backstop trigger on coach_availabilities fires on each availability INSERT and must be able to see the new exceptions — which requires they exist BEFORE the trigger reads. (2) Update check_booking_against_coach_reservations (migration 20260512102224) to scope its existing exception subtraction by court_id (ae.court_id IS NULL OR ae.court_id = ca.court_id) — both court_booking and lesson_court branches. (3) Update coach_availability_conflicting_bookings (the route-level matcher, migration 20260522082857) to subtract exceptions identically using the same court-scoped predicate. Both layers now see the same shape — DEC-228's 'consistency between layers' rationale for ignoring exceptions is satisfied.
Alternatives considered
Architect's round-2 critical correction in the multi-agent debate: the per-court column and the gate fix are ORTHOGONAL — adding court_id alone (without reordering the INSERTs) doesn't fix the TOCTOU because the trigger still fires before exceptions exist in the same transaction. The INSERT-reorder is what makes the trigger exception-aware. With the reorder PLUS court_id, the trigger and the matcher both subtract on identical keys (coach, facility, court_id, date, time-range) — DEC-228's original concern (self-inflicted inconsistency if only one layer was exception-aware) is satisfied. BUG-093's documented recipe in known-issues.ts:127-142 specifies this exact change.
Add optional court_id uuid NULL REFERENCES courts(id) ON DELETE CASCADE to availability_exceptions. NULL = all reserved courts (preserves today's semantics for every existing row). NOT NULL = scoped to that one court. Matches the per-court grain of coach_availabilities (one row per court). New optional column, zero data migration, additive change. The R6 gate fix (DEC-238) is bundled in the same PR because gate-honors-exceptions is a prerequisite for either A or B to actually unblock the manager. The inline 'Ignore this conflict' button (DEC-239) is the UI affordance that makes per-court remediation discoverable at the point of friction.
Alternatives considered
Multi-round multi-agent debate (architect / UX realist / DB auditor / minimalist). Round 1: architect = B, UX = B, minimalist = A+D. Round 2 cross-flip: architect → A+D (conceded BUG-093 recipe specifies the fix without column); minimalist → B+D (conceded multi-court silent destruction kills A); UX held B+D. Decisive evidence from UX's round-1 product survey: 5 PMS-domain products (EZFacility, CourtReserve, Mindbody per-room overrides, Mews, Playtomic) all model resource-scoped exceptions when availability is resource-scoped. Zero counter-examples in the right domain. v0.29.0 precedent: when industry survey backs the manager's literal mental model AND the schema's grain mismatch is real, we add the optional field. The architect's flip was technically correct on a separate question (HOW to fix R6 doesn't require the column — see DEC-238) but didn't engage WHETHER to add the column for grain consistency. The multi-court footgun + PMS-domain convergence carry the column decision.
Composite (a)+(b)+(c-without-the-DB-invariant). UI: drop the `> 1` guard at booking-players-input.tsx:266 (+ aria-label, data-testid, refocus search on empty). Dialog: onPlayersChange branches on `updated.length === 0` and fully resets organizer-derived state (guestEmail, guestFirstName/LastName/Phone, customerMatch, memberInfo, passInfo) so the wrong pick's member/pass/contact fields can't leak into the next submit. Backend: DELETE /api/admin/bookings/[id]/players promotes a remaining player to organizer when the organizer row is removed (mirrors the UI's handleRemovePlayer behavior; covers non-UI consumers too). Originally included a partial unique index `booking_players_one_organizer_per_booking` ON booking_players (booking_id) WHERE is_organizer = true with a fail-loud DO-block pre-flight check. **Update 2026-05-27T16:30: the partial unique index was REVERTED before prod deploy.** The pre-flight correctly aborted on 1 pre-existing prod booking (Alba Garrido, 2026-04-24) where bookings.user_id/guest_email point at Alba but Alba is not in booking_players at all (4 players present, 0 organizers). Filed as BUG-096. The partial unique would not have prevented this case anyway (it only guards >1, not 0), so re-introducing it requires deciding the orphan-organizer cleanup pattern first — out of scope for this PR. The application-layer invariant (POST/UI always sets is_organizer=true only for index 0; the new DELETE promotes a successor) is maintained without the DB constraint; the constraint was defense-in-depth, not load-bearing. Deferred (d) and several UX adjacents (labeled 'Change' affordance, click-crown-to-swap-organizer, dropdown disambiguation, full organizer-as-first-class column refactor) to follow-up PRs — blast radius (payment-link emails, check-in, TPS webhooks) warrants its own scope; dual-state risk between `guestEmail` and `players[0].email` is dormant today and this PR's parent-state reset closes the only active manifestation.
Alternatives considered
Pre-production boldness + No-debt mindset (both CLAUDE.md STRONG BIAS). Patching the UI alone would leave the backend DELETE asymmetric — a third-party caller could leave a booking with no organizer, and no DB invariant catches it. Closing the same area's silent data-integrity hole in the same PR is cheap (≤30 lines of route logic + a 50-line migration with a fail-loud pre-flight), low-blast-radius (no consumer in prod relies on the absence of the index), and removes a class of bugs from future review. The 'Change' label was rejected after the multi-agent debate confirmed it solves a different (post-creation) problem than the one operators hit here. The full organizer-column refactor was deferred because its dormant-risk threshold is too low to justify the cross-system review cost right now — the parent-state reset in this PR closes the only active manifestation. Multi-agent debate trail (4 perspectives + synthesizer) persisted at /workspaces/setpoint-3/plan-organizer-clear-fix.md so the follow-up PRs don't re-litigate.
Two parts. (1) changeRecurrenceType (coach-schedule-editor.tsx:179) carries start_date/end_date across weekly/monthly/date_range switches. For days_of_week, the carry is ASYMMETRIC: date_range→weekly carries it (an explicit operator selection like 'Wed' is worth preserving), but weekly→date_range does NOT (a freshly-added row defaults to weekly+Mon as a non-empty placeholder — not an intentional selection — and silently carrying [1] into date_range would surprise operators who expected 'every day in range'). Switching to single_date drops everything. (2) When date_range has no days_of_week key, render all 7 chips as visually selected with muted '(all weekdays — tap to restrict)' helper text. On first chip toggle, materialize days_of_week to [all 7 except toggled]. If toggling brings the array back to all 7, collapse the key to undefined (omit from save). NEVER materialize [0,1,2,3,4,5,6] explicitly — that pollutes JSONB with a redundant default, breaks coalesceRows hash compatibility with pre-A+B rows, and makes an operator who never touched the chips have a row that LOOKS edited.
Alternatives considered
Backward compat + minimum surprise. Pre-A+B prod date_range rows have no days_of_week key — they mean 'every day in range'. Post-A+B with no days_of_week key, semantics are identical. Carrying selections across type switches matches what operators were trying to express. Explicit absence ('omit when all 7') round-trips cleanly through coalesceRows and the SQL materializer.
Yes — REWRITE validateProgramSessionsAgainstAvailability (packages/common/src/coach-availability.ts:185). (a) Drop the recurrence_type === 'weekly' filter. (b) Add a new required ProgramSpan { start_date, end_date } argument. (c) For each session, enumerate dow occurrences inside the span. (d) Require at least one row that applies on each occurrence AND covers the time window. This makes bounded weekly AND date_range[days_of_week] both program-compatible iff they cover the full span. Both callers (programs POST at apps/admin/src/app/api/admin/programs/route.ts:175, programs PATCH at apps/admin/src/app/api/admin/programs/[id]/route.ts:118) thread the program's effective start/end through. Without this rewrite, an operator who bounds a weekly schedule OR uses date_range[days] sees program creation silently fail with 'Instructor has no recurring weekly schedule' even though the schedule covers every session.
Alternatives considered
Architecture-first (CLAUDE.md): the validator exists to catch obvious 'coach can't cover this' cases before the DB trigger fires. Under A+B, bounded date_range[days] IS a recurring weekly schedule with explicit bounds. Recognizing both shapes keeps the validator honest with the data model. Adding ProgramSpan params is unavoidable — without them, the validator can't tell whether a bounded row covers the program span. No-debt mindset: a half-updated validator that accepts weekly+bounds but not date_range[days] is the exact debt we'd apologize for in 6 months.
Accept the semantic redundancy. Both shapes produce identical SQL filtering — coach_availability_applies_on_date returns the same boolean for the same (date, config) pair under both shapes — and identical materialized date sets via coach_availability_dates. The editor preserves whichever shape the operator chose: coalesceRows (apps/admin/src/app/api/admin/coaches/[id]/schedule/route.ts:254) uses JSON.stringify(recurrence_config) as bucket key, so the row round-trips as written. No DB-level normalization, no migration to collapse shapes.
Alternatives considered
Not data corruption — both shapes are byte-identical in filter semantics. Only cost is hypothetical analytics ('count of bounded recurrences') which would need an OR across both shapes; today's reporting doesn't ask that question. Cost of normalization (operator surprise + write-time mutation) outweighs cost of an OR in a future query. Editor bucket-key fragmentation is acceptable — we explicitly do not promise dedup across shapes.
Ship A+B together as one feature in v0.29.0. (A) date_range.recurrence_config gains optional days_of_week — rows match dates in [start_date, end_date] AND in days_of_week if present. (B) weekly and monthly recurrence_config gain optional start_date/end_date — rows match the recurrence AND fall inside the bounds if present. Both fields are additive; omitting them preserves today's behavior. No new recurrence_type. SQL update lands in coach_availability_applies_on_date + coach_availability_dates (new migration); TS in packages/common/src/coach-availability.ts. No data migration of existing rows. No Rust changes (recurrence_config is opaque JsonValue on the Rust side). validateProgramSessionsAgainstAvailability is rewritten — see DEC-234. Editor preserves whichever on-ramp the operator chose — see DEC-233 (redundancy accepted) and DEC-235 (carryover + chip defaults).
Alternatives considered
Multi-round multi-agent debate (architecture purist / UX realist / DB auditor / minimalist) converged on A+B after the UX agent corrected a wrong-domain mistake in round 1. The manager named two distinct complaints in one paragraph: unbounded weekly AND missing day filter on date_range. Picking only one leaves the other live. A+B fixes both at once with bounded surface area (~13–14 files / 5 new E2E tests, vs B's ~10–11 / 2, or C's ~11–12 / 5–6). PR #679. The ephemeral AC + plan file was deleted in a follow-up chore PR per CLAUDE.md feature workflow step 13; lasting context now lives in DEC-232–235 + the migration + the code.
(A) Add `.lte('start_time', now.toISOString())` to the bookings query so every downstream JS filter inherits a bounded window — fixes Bug 1 in one place for both the 7d cards and the 30d chart. (B) Replace the `12 * 60 * 7 * courtCount` constant with a new shared helper `apps/admin/src/lib/court-open-hours.ts → computeAvailableMinutes()` that reads `court_schedules` and sums per-(court, day_of_week) open minutes across the rolling 7-day window. Returns both `total` and `byCourtId` so the top-card and per-court tables share the same denominator. (C) Wrap every utilization percentage in `Math.min(100, Math.round(...))` as defense-in-depth — the formula is already bounded ≤ 100% when the window is correct, but a stale schedule (court closed in DB, bookings still on it) would otherwise read >100% again.
Alternatives considered
The bugs share root causes (missing upper bound, hardcoded constant, no clamp) so they're addressed in one PR with three orthogonal fixes — Bug 1 fixed at the SQL layer (smallest possible reach), Bug 2/3 fixed via a reusable helper, plus the defensive clamp. The helper centralises 'open minutes per court' so the top-card and per-court table can never drift again. Academy-reports audited in the same PR — it uses `facilityDateBoundaries` for both lower and upper bound and has no impossible-value formulas, so no fix needed (documented in the PR description so the next agent doesn't re-litigate). Verified against prod (read-only) before and after: PTP 'Utilization' goes from 309%→59% and 'Bookings (7d)' from ~1,368→218. E2E TC-033 extended with regression tests asserting Bookings(7d)===5 excludes a +30-day future booking and utilization ∈ [0,100] (top-card + per-court).
(1) One shared cancelLessonCascade() helper (apps/admin/src/lib/cancel-lesson-cascade.ts): idempotent (status-gated, link-nulled refunds), refunds package sessions once, cancels every linked court booking, then flips lessons.status='cancelled' so the existing emit_lesson_status_event trigger emits lesson_cancelled. Called from ALL three cancel surfaces — PATCH /api/admin/lessons/[id] (refactor), PATCH /api/admin/bookings (when the cancelled booking is lesson-linked via lesson_courts), and POST /api/v1/me/bookings/[id]/cancel. (2) Migration 20260525163034: emit_booking_event early-returns for booking_type='lesson', so a lesson's court booking never emits booking_* — lessons travel only on the lesson_* channel (DEC-226). The already-shipped TPS lesson webhook (padel-backend #211) then cancels the coaching record idempotently. No new protocol surface, no Rust change.
Alternatives considered
Native-equivalence + 'OpenRally is just a protocol — reuse existing patterns': a Setpoint-admin cancel must reflect on the consumer identically regardless of which surface initiated it. Flipping lessons.status routes the cancel onto the lesson_* channel the consumer already handles correctly (#211), so the fix is plumbing with zero new product/protocol surface. One helper = one source of truth for 'cancel a lesson' (refunds-once, idempotent), so no future cancel path can orphan a lesson again. Suppressing booking_* for lesson bookings removes the duplicate court hold at the producer side, consumer-agnostic. Root cause confirmed via a 4-agent exploration + adversarial debate (verified against prod, read-only).
Mirror the forward gate's occupying set exactly: count confirmed / checked_in / completed AND active (non-expired) holds; exclude cancelled / expired / no_show. Include OTHER coaches' lessons (a reservation over another coach's lesson on the court is a real conflict); exclude THIS coach's own lessons (a reservation over the coach's own lesson is consistent, not a conflict). Only future/ongoing bookings (end_time > now()). availability_exceptions are NOT subtracted — both the route enumerate fn and the backstop trigger ignore them so the two layers agree.
Alternatives considered
Symmetry with the forward gate (DEC-196) keeps one mental model of 'what occupies a court'. Over-flagging is the safe failure direction for a blocking gate: the worst case is the operator sees a conflict for a booking on an excepted date and resolves it; the gate never silently permits the orphan overlap the feature exists to prevent.
Hybrid. One shared SQL matcher (coach_availability_conflicting_bookings) consumed two ways: (1) coach_availability_booking_conflicts() enumerates ALL conflicts for a proposed payload — the PUT /coaches/[id]/schedule route calls it BEFORE coach_schedule_replace and returns a structured 409 listing every conflict (court + date + time + customer), writing nothing; (2) a per-row BEFORE trigger on coach_availabilities (coach_availabilities_check_booking_conflict_trigger) raises 'court_has_existing_booking' as the correctness backstop for the check→replace TOCTOU race and any future non-route writer. Migration 20260522082857, shipped/deployed BEFORE the feature PR per Hole 23.
Alternatives considered
Mirrors the established pattern for this gate family: DEC-202 already does app pre-check (for UX) + DB trigger (for the invariant) in the forward direction. Writing the matcher ONCE in SQL (reusing coach_availability_applies_on_date + the facility-TZ + numrange machinery proven in check_booking_against_coach_reservations) means no TS/SQL duplication and no drift, while the route keeps name composition (presentation) out of SQL. Bypass-proof and enumerates all. While building, the gate caught a latent orphan overlap in seed.sql (a court booking sitting inside the demo coach's reservation) — fixed.
Extend the reverse-flow to admin-originated court bookings, origin-discriminated TPS-side. (1) Setpoint: new migration 20260520100304 adds primary_player_email (COALESCE(NEW.guest_email, customers.email via NEW.user_id)) + primary_player_phone to the emit_booking_event payload — bookings has no created_by column, so identity (not origin) is all the payload carries. (2) TPS (padel-backend, owned by TPS team): dispatcher routes booking_created to a new delegate_creation action; index.ts reuses its existing booking_third_party_references lookup as the LOOP GUARD — ref exists ⇒ TPS-originated ⇒ ack (no write); ref absent ⇒ admin-originated ⇒ ExternalBookingCreationService writes the full TPS-native row shape. CRITICAL: the BTPR key is the RAW or://setpoint/booking/<uuid> URI (NOT the bare uuid the lesson handler uses), because TPS's own outbound court bookings store the URI in third_party_reference and index.ts + the cancel path look up by it — diverging would silently miss the loop guard and break cancel.
Alternatives considered
Native-equivalence rule (CLAUDE.md): an admin-created booking must render in TPS identically to a self-booked one — same match-gated screens, same cancel button, same team membership. This mirrors the lesson reverse-flow (DEC-225) and the dual-store integration pattern affirmed in DEC-226 (each side keeps its native record; the protocol syncs them). Cancel symmetry needs ZERO new code: once the reverse-write creates a BTPR keyed by the URI, the existing booking_cancelled path (index.ts → ExternalBookingCancellationService → cancel-booking-v1) cancels the native rows. Verified: Setpoint migration locally (guest-email, member-fallback, anonymous paths all emit correctly); TPS dispatch + loop-guard key invariant via deno unit tests (dispatcher_booking_created.test.ts, 7 passing); my TPS files add zero type errors. Cross-system happy-path + live loop-regression require the TPS team to deploy the handler half (not deployable by us; no writes to TPS prod).
(A) — revert DEC-205/DEC-208 and write full TPS-native row shape for OpenRally coach bookings in both directions. Forward flow (create-coaching-booking-v2 + confirm-coaching-booking-v2) writes coach_reservations at HOLD + flips to confirmed + inserts coaching_slot_details at CONFIRM, mirroring legacy lifecycle. Reverse flow (openrally-lesson-webhook) writes all 5 native rows (bookings, booked_slots, coach_reservations, coaching_slot_details, btpr) on inbound lesson_created. Cancel path already symmetric via existing webhook handler. Idempotent via btpr unique constraint. Costs paid once at the integration boundary — every downstream consumer screen renders unchanged.
Alternatives considered
Industry pattern: every OTA ↔ PMS integration of meaningful scale (Booking.com EQC, Expedia EQC, Airbnb host APIs, Google Reserve) uses dual-store with the protocol as the sync layer — each side keeps its own native record, protocol events keep them in sync. Channel managers (SiteMinder, Cloudbeds) exist as commercial products built on this pattern. TPS itself already uses this for every other PMS integration (Sterling, BookAndGo, Kross, Playtomic, Taykus, Matchi, Junglepadel) — all write full TPS-native bookings + coach_reservations rows. Setpoint/OpenRally being the outlier was the bug. The 'redundancy' objection is structurally wrong: the two records aren't duplicates, they're separation of concerns — Setpoint's row drives admin calendar/commission/instructor schedule/billing; TPS's row drives the consumer's coaching list/push notifications/cancel-via-TPS flow. Per CLAUDE.md no-debt-no-compromise principle: pay the cost at the integration boundary once, never push complexity onto consumer screens.
(B) — webhook-driven writeback. The full chain: AFTER INSERT trigger on Setpoint `lessons` inserts an `or_events` row with `lesson_created` event including the primary player's email. The existing OpenRally webhook dispatcher (already live, line 37 of webhook_dispatcher.rs) POSTs that event to TPS's registered endpoint. A new TPS-side Edge Function receives the webhook, looks up the TPS user by email (per DEC-223), and writes the three rows TPS's existing UI reads from: `bookings`, `coach_reservations`, `booking_third_party_references` (mirroring the proven Sterling/Kross/Playtomic pattern). TPS's `ProfileCoachingScreen` and its `getMyCoachingSessions()` query are unchanged — they render the writeback rows identically to TPS-initiated bookings. Setpoint admin UI is also unchanged — the existing Create Lesson dialog with its email field is the only entry point. Cancel symmetry handled by the same Edge Function listening for `lesson_cancelled` events (already wired by `trg_lessons_emit_status_event`).
Alternatives considered
Architecture-first principle (CLAUDE.md): webhook writeback is the cleanest path because (1) it reuses every existing pipeline component — the trigger, or_events, webhook_dispatcher, signing, retries are all live; (2) it respects 'no UI changes on either side' — TPS's existing ProfileCoachingScreen renders the new rows without code changes; (3) it mirrors the proven Sterling/Kross/Playtomic pattern already in TPS for booking_third_party_references — zero new architectural ground; (4) it keeps OpenRally a pure protocol — the webhook payload is consumer-neutral, the next consumer (BookAndGo, future bot) just needs to register an endpoint and write to its own table; (5) cancel symmetry is automatic — the existing lesson_cancelled trigger already fires, the same Edge Function adds a case branch. Industry-aligned: Stripe Connect, Plaid, Twilio all use webhook-driven push for 'producer notifies consumer of state change' rather than polling. Counter-arguments stress-tested: race condition (admin creates → admin cancels within seconds) — webhook retries idempotently and TPS handler checks status before mutating; missed webhook (TPS endpoint down for hours) — webhook dispatcher retries with backoff, surfaces failures via existing webhook_endpoints schema for ops; replay attacks — HMAC signing on webhook body, unique third_party_reference on TPS side prevents row duplication. Self-debate also confirmed: per the new 'OpenRally is just a protocol' CLAUDE.md principle, this design adds zero UI surfaces on either side and zero new TPS-specific concepts in OpenRally — exactly what the principle demands.
(b) — extend confirm_lesson_from_hold to insert the lesson_players row atomically with the existing bookings UPDATE. Two-part migration (20260515084759_openrally_confirm_lesson_inserts_player.sql): (1) CREATE OR REPLACE FUNCTION confirm_lesson_from_hold adding INSERT INTO lesson_players (lesson_id, user_id, email, phone, is_primary, payment_method) reading from the just-UPDATEd booking row, idempotent via NOT EXISTS guard, mirrors create_lesson_atomic column-for-column; is_primary=TRUE since OpenRally coach lessons are 1-on-1 by design (DEC-207). (2) One-row backfill for the existing OpenRally lesson already in prod (booking #59352 / lesson 3f0177c3-…) with WHERE clauses that structurally exclude admin-native lessons (lp.id IS NULL on the LEFT JOIN). Verified end-to-end via Android emulator on 2026-05-15 evening: new booking #59389 → lesson bc7a0c28-… auto-populated lesson_players (email=gianfranco+1@omnigpt.co, is_primary=true, payment_method=external). TC-143 extended with a lesson_players assertion to lock the regression.
Alternatives considered
DB-layer fix has two structural advantages: (1) atomicity with the existing UPDATE bookings — same transaction, same row lock — so no race window where the booking is confirmed but the player row is missing; (2) the RPC's documented contract is 'hold → confirmed transition,' so anything else that calls this RPC in the future inherits the correct roster behavior without having to remember to insert separately. The fix follows the existing pattern from create_lesson_atomic (initial_schema.sql:1421) — same columns, same primary-flag default, just gated by NOT EXISTS to keep the RPC idempotent on retries. Backfill is verified-safe scope: 1 lesson globally on the test facility, and the WHERE structurally excludes any admin-native lesson via lp.id IS NULL. Industry-aligned: this is the same shape as Stripe's webhook-driven 'finalize customer record on subscription.created' — the state transition completes the entity, not just one field. Architecture-first principle (CLAUDE.md): the root cause is in the RPC's incomplete contract; the patch fixes the contract, not the symptom.
Read C — email-matching with ZERO TPS prod writes. Steps: (1) Pick any real existing TPS coach with populated users.email. (2) Mirror that coach into Setpoint admin's coaches table with the SAME email, mark default_class set at Pad Thai (the OpenRally-routed test club) so they're bookable via OpenRally. (3) OpenRally coach response payloads gain an `email` field + new `GET /coaches/by-email` lookup endpoint (additive, backward-compatible). (4) TPS discovery extension matches OpenRally coaches to existing TPS coach_profiles by `users.email = openrally_coach.email`; on no match, skip + warn (don't fail loud — protects against drift). (5) TPS booking dispatcher reads email from the coach_profile and calls OpenRally's /coaches/by-email to translate to Setpoint UUID, then proceeds with the lesson hold/booking. TPS prod stays read-only this release. The previous `openrally_coach_uri` column migration is dropped (deferred for the future automation work — see handoff-future-coach-mapping-automation.md).
Alternatives considered
Email is the natural cross-system identity for a coach. Both sides already store it (Setpoint admin's coaches.email and TPS's users.email). Matching by email avoids ALL TPS prod writes, all schema changes, and uses the operator-managed value that's already the canonical 'this is the same person' signal in HR/payroll/identity flows. The known fragility — email changes break the link, and email-typos in either system silently miss — is accepted at v0.26.0 scope (1 coach, easy to verify) and documented as identity-load-bearing. When scale forces real automation (3+ OpenRally clubs, or operator friction from manual Setpoint-admin mirroring), shift to a typed external_id mapping table on Setpoint admin (consumer-agnostic — `coach_external_ids(coach_id, consumer_label, external_id)`) + an OpenRally lookup endpoint. Full context for that future work lives at apps/docs/src/data/handoff-future-coach-mapping-automation.md so the next agent has the trail without re-deriving it. Industry-aligned: this is the same pattern as 'find user by email' in Stripe Connect / Plaid / Auth0 federation — email is the universal identity bridge across vendor lines until typed federation IDs justify the cost.
(4) Two-stage hybrid: /coaches/search is the cheap pre-filter and the authoritative distance source; /facilities/{id}/coaches/{id}/availability fills the slot times for coaches that pre-filtered positive on the requested date. TPS calls /coaches/search per OpenRally-routed club with the user's lat/lng + radius_km + ?date=, gets coaches with has_availability + distance_km, then fans out per-coach availability ONLY for coaches with has_availability === true. Skip the fan-out entirely for coaches the pre-filter excluded (saves N round-trips when most coaches have no availability that day). Maps the returned LessonSlotResponse[] into TPS's existing ClubCoachAvailabilityResult shape so filterCourtsByAllCoachesAvailability and downstream rendering work unchanged.
Alternatives considered
Industry-standard two-stage discovery is the universal pattern for aggregator-style discovery + per-item detail: OpenTable / Resy return location + a thin availability hint, then per-restaurant calendar is a separate call; Airbnb returns ranked listings with an availability hint, the per-listing calendar is a separate call; Stripe Connect / Plaid Link return institution/account discovery, then per-account detail. All three deliberately keep the search response thin and let the per-item endpoint carry the dense data. /coaches/search + /facilities/{id}/coaches/{id}/availability is the exact same split. Counter-arguments stress-tested: (a) 'isn't Path 1 fanning out per facility just Approach A in disguise?' — no, Approach A was MULTI-FACILITY aggregation at the consumer; this is PER-FACILITY slot expansion, an inherently per-resource operation that every consumer must do regardless of protocol; (b) 'isn't /coaches/search redundant then?' — no, it owns distance + has_availability pre-filter, both of which we'd otherwise duplicate in every consumer; (c) 'what if a future use case needs slot-level discovery?' — that's exactly what the two-call pattern serves; the extra round-trip cost is bounded (a single coach's per-date slot fetch is cheap) and only paid for coaches that passed the pre-filter. Consumer-agnostic + no-debt principles both favor (4): TPS doesn't know geography, OpenRally does; TPS doesn't know coach calendar semantics, OpenRally does; both come from the protocol.
(c) — per-court swim lanes inside each day-cell in all-courts mode. When `selectedCourt === "all"`, each cell flexes into one sub-lane per court (horizontal, left-to-right, ordered by the `courts` array so Court 1 is always leftmost). 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. Each lane independently renders its own bookings with a span based on duration. Day-column min-width scales to `max(110px, 36px × courts.length)` so lanes stay usably wide as court counts grow. The day-header shows a chips strip naming each court aligned to the lanes below. 2hr bookings span vertically within their own court's lane only (TC-142 invariant preserved). Single-court mode (when a specific court tab is selected) renders as one lane with no visual change — zero regression. New helpers: `getBookingsForCellAndCourt`, `isSlotOccupiedForCourt`. `data-testid` hooks on each lane and empty button (`week-lane-{ds}-{tick}-{courtId}`, `week-empty-{ds}-{tick}-{courtId}`) make the per-lane interactions assertable. TC-145 locks the new behavior.
Alternatives considered
Per-resource swim lanes is the industry default — Google Calendar resource view, Calendly resource pool, Acuity/Mindbody staff column, OpenTable table row, Tock/Resy table lane all render one column per resource per day. None of them resolve a cell click via 'guess the first resource.' The bug exists precisely because the original aggregated-cell design diverged from this pattern. Counter-arguments stress-tested: width concerns at high court counts — addressed by dynamic min-width scaling at 36px/lane; spanning multi-hour bookings — they continue to span vertically within their own lane (the existing absolute-positioned card sized by duration translates directly to the new sub-lane container, with TC-142 preserved); single-court mode regression — explicitly avoided by branching on `selectedCourt === "all"` and rendering the single-court tab identically to today. CLAUDE.md 'architecture-first, not patches' and 'no-debt, no-compromise mindset' both demand the structural fix over the dialog-picker band-aid, even though the band-aid is smaller.
TPS team applies migrations manually — same workflow as Setpoint. Verified 2026-05-14 by comparing local supabase/migrations/ in padel-backend (9 files) to the remote schema_migrations table in TPS prod (1 row, unrelated). The mismatch proves `supabase db push` is NOT used. The migrations directory is a change-log for code review and history; SQL is applied by a TPS team member via the dashboard editor or `supabase db query --linked --file` after PR merge.
Alternatives considered
Manual application is the working norm for both teams. Our PR ships the SQL file in the standard location; TPS team applies it post-merge the way they always do. No new tooling, no surprise deploy mechanism.
Merged. Single sorted list, no sectioning, no badge identifying source. The user sees 'coaches near me' and books one; the protocol is invisible.
Alternatives considered
Operator constraint: preserve mobile UI/UX exactly. Industry-standard: marketplace meta-search hides source from end user. Source-of-truth is OpenRally for the routing, irrelevant to the booking user.
Single optional `?date=YYYY-MM-DD` (facility timezone). Response includes `has_availability: boolean | null` per coach (null if ?date absent, boolean if present). Defer date-range and per-day slot counts to a later release.
Alternatives considered
Matches TPS mobile's current UX exactly. Single-day filter is industry-standard for instructor marketplaces (ClassPass works this way). Additive extension to date-range later is non-breaking.
Defer. No /facilities/search at v1. Reserve the path in the OpenAPI spec as 'planned' for future consumers to see the direction.
Alternatives considered
Building unused endpoints on a protocol is exactly the kind of speculative work CLAUDE.md's 'don't design for hypothetical future requirements' rule discourages. When a consumer actually needs facility-first discovery, design the endpoint against their real requirements.
Defer. OpenRally response does not include rating/review fields at v1. TPS mobile renders 'New' or hides the rating row for OpenRally coaches (TPS-side rendering decision). When Setpoint builds a review system (separate product cycle), the fields are added to the OpenRally response shape — purely additive, backward compatible.
Alternatives considered
Reviews are a substantial product feature on their own merit. Coupling them to a discovery release would block both. Additive field extension is the cleanest protocol evolution path.
Add `coach_profiles.openrally_coach_uri text UNIQUE` to TPS DB (nullable). When TPS backend gets discovery results from OpenRally, it upserts a coach_profiles row keyed by URI. Mobile sees a normal coach_profile with a numeric ID, sends that ID back for booking, TPS backend reads the row, sees openrally_coach_uri is set, routes via OpenRally. Discovery still queries OpenRally LIVE on every search — the upsert is a translation cache, not the search index.
Alternatives considered
Smallest possible change to satisfy 'mobile unchanged' constraint. Discovery still calls OpenRally live every search — the mirror is OUTPUT of each discovery, never INPUT to a search decision. Revoke handled by OpenRally simply not returning a removed coach; orphaned mirror rows become inert. Stripe Connect pattern: local translation table, not source-of-truth replica.
Use existing facility-scoped api_keys as-is. /coaches/search resolves the key to its facility_id and restricts results to coaches at that facility. The endpoint NAME is forward-compatible (cross-facility-shaped URL) but the BEHAVIOR at v1 is per-facility-per-call. When a real second-facility use case emerges (TPS opens Pad Thai Sathorn, or a new consumer integrates with multi-facility access), extend the auth model then — driven by an actual use case, not speculation.
Alternatives considered
Lean-now-extend-later (CLAUDE.md no-debt mindset interpreted correctly): the existing model is correct for its scope, not debt. The /coaches/search shape commits us to the right direction; the auth-scoping work happens when there's a real use case driving the design. Non-breaking when extended: existing endpoints continue to accept facility keys.
Approach D: protocol-owned cross-facility discovery. Add `GET /openrally/v1/coaches/search` to OpenRally. TPS backend calls it during discovery, lazy-upserts results into coach_profiles via the new openrally_coach_uri column (DEC-214), merges with TPS-native coaches, returns to mobile unchanged. Discovery aggregation lives in the protocol (where it belongs), not in consumer code.
Alternatives considered
Industry research (Kayak, OpenTable, ClassPass, MindBody, Apple HomeKit, Stripe Connect, conducted by sub-agent) showed every comparable marketplace centralizes the discovery surface in the protocol/marketplace layer, not in per-consumer aggregation code. Approach D matches that pattern. The 'lean now extend later' shape (single-facility-per-key call at v1, multi-facility when a real use case emerges) reconciles the architectural commitment with operator's 'simple is fine' framing for the api_keys model. Survives all three adversarial review rounds run via parallel sub-agents.
(b) — turn `enableSystem` off in apps/admin/src/app/layout.tsx and make the toggle read `resolvedTheme` (what's rendered) instead of `theme` (the chosen setting) in both apps/admin/src/components/ui/theme-toggle.tsx and apps/admin/src/components/admin/admin-sidebar.tsx. Drop the suppressHydrationWarning band-aids in admin-sidebar.tsx and replace with a proper mounted gate. No new UI surface. The two-mode toggle now actually means two modes.
Alternatives considered
CLAUDE.md 'Architecture-first, not patches': the smell is the gap between what the UI promises (two modes) and what the code provides (three, one hidden). The architecturally correct fix is to make the code match the advertised UX, not to expand the UX to match the accidental code path. Industry check: Linear, Notion, and Vercel dashboards all expose 'system' as an explicit toggle option — none of them silently follow OS without consent. We don't have that toggle, so we shouldn't covertly behave as if we do. The `resolvedTheme` swap is technically required regardless of `enableSystem` — `resolvedTheme` is semantically 'what's on screen' and is the field the next-themes docs recommend for binary-toggle UX; using it keeps the toggle correct if anyone ever re-enables system mode later.
Extend the existing cancellation_policies table with `applies_to_lessons` (boolean, default true). Backfill existing rows to true. Existing operator UI in operations-settings-tab.tsx gains a third toggle. OpenRally cancel endpoint returns refund_eligible based on the facility's policy + hours-until-start.
Alternatives considered
Pure extension of existing schema + UI. One flag, one backfill, one new toggle. The cancellation SQL helpers already read v_policy.free_cancellation_hours; they just need the `applies_to_lessons` gate at the front. CLAUDE.md bias toward built-in / fix-root-cause applies — this brings lessons up to par with the rest of Setpoint instead of inventing a parallel policy framework.
Add HTTP webhook out from OpenRally (lesson_created, lesson_modified, lesson_cancelled, plus existing booking_* events). Each facility configures one or more `openrally_webhook_endpoints` rows (url + signing_secret + event_types). Signed via HMAC-SHA256. Retries 3x with exponential backoff on 5xx/timeout. SSE stream stays — consumers can subscribe to either or both.
Alternatives considered
Industry-standard webhook-out (Stripe, GitHub, every PMS we integrate with) is the canonical pattern for cross-system state propagation. Per-facility config keeps OpenRally vendor-agnostic — any consumer can register a URL, not just TPS.
Mirror admin behavior: OpenRally lesson booking calls find_or_create_customer_by_email with the TPS user's email + name + phone. If the customer exists, lesson links to that user_id; otherwise a new customers row is created. Setpoint admin sees a real customer record (no 'guest' badge for TPS bookings).
Alternatives considered
Matches the admin flow exactly. Existing helper (find_or_create_customer_by_email) is already in use; no new code path. Identity continuity for repeat TPS bookers is a real product win for operators.
OpenRally lesson bookings get a hold phase identical to court bookings. New atomic RPC hold_lesson_slot mirrors hold_slot: inserts lesson + bookings + lesson_courts in 'hold' status with hold_expires_at = NOW() + ttl. Confirm RPC transitions to 'scheduled'/'confirmed'. Setpoint admin's direct-commit path is unchanged (admin trust is implicit; protocol consumers need the soft-lock).
Alternatives considered
Protocol consistency wins. OpenRally is intended to serve multiple consumers (not just TPS) and multiple PMSs (not just Setpoint); a hold phase is the canonical pattern for cross-system reservations. Setpoint admin can keep its direct-commit path because admin actions are inherently trusted.
V1 scope: 1-on-1 private single-court lessons only. Group lessons stay admin-only for v1 and will be handled in a follow-up feature (NOT through TPS events, per CEO override). Multi-court lessons out of scope entirely for v1.
Alternatives considered
CEO explicit scope ruling. Manager initially suggested 'group through events' — CEO overrode: once OpenRally lesson booking is in, group coaching gets its own dedicated path, not folded into events. Demo target (Pad Thai Padel) is 1-on-1-first anyway.
Add `coaches.default_class_id` (uuid, nullable, FK to classes.id). Setpoint operator marks ONE class per coach as the TPS-bookable default via a dropdown on the coach edit page. OpenRally /coaches endpoint only lists coaches whose default_class_id is set. Coach edit page shows a 'Not bookable on TPS — set a default class' warning when null. The coach owns the relationship (one FK column on coaches, not a partial unique index on classes).
Alternatives considered
CEO explicit ruling: 'option A for this'. Architecturally, putting default_class_id on coaches keeps the relationship single-rowed and avoids the 'partial unique index on a generic table' pattern. Failure mode (no default set) is loud and visible to the operator at the coach edit page.
Approach B — coaches as first-class resources. New endpoints: GET /facilities/{id}/coaches, GET /facilities/{id}/coaches/{coach_id}/availability, POST /lesson-holds, POST /lesson-bookings, POST /lesson-bookings/{id}/cancel. New URI variant: or://setpoint/lesson_slot/{coach_uuid}_{court_uuid}_{YYYYMMDD}_{HHmm}. Materializer extended to emit lesson slots in addition to court slots. SSE + webhook events get lesson_created / lesson_modified / lesson_cancelled types.
Alternatives considered
Industry standard: MindBody, Acuity, Playtomic, Calendly all model staff/coaches/services as a separate resource type from facility-level resources. Both sides (Setpoint admin DB and TPS backend + mobile) already split coach booking from court booking — Approach B keeps the protocol consistent with that split. The 'more API surface' counter-argument is the necessary surface for a fundamentally different domain (people-as-resources vs rooms-as-resources). Survives two rounds of self-debate (multiple coaches sharing court, reserved-on-court conflicts, distinct webhook events) — all cleaner with separate endpoints.
Per-occurrence facilityLocalToUTC(localDate, clickTime, tz). Each occurrence's wall-clock hour stays the same regardless of DST transitions in the range. No millisecond addition anywhere in the recurring path.
Alternatives considered
facilityLocalToUTC already handles the DST-aware conversion correctly (it's used by every other tz-sensitive admin path). Adopting it per occurrence gives one source of truth and one fewer landmine when v0.19.0 lands.
Rewrite TC-116 to use the new shape (six atomicity scenarios preserved verbatim, inputs translated via a `recurrencePayloadFor(start, occurrences)` helper). Add TC-141 as a separate file dedicated to the new fanout + validation surface (multi-day, coach skip, error matrix, UI happy path). tc-075 and tc-078 each carry one line that sends `recurring_weeks` for unrelated reasons — migrated in the same PR.
Alternatives considered
Two focused test files beat one bloated file. The translation helper keeps the diff small; TC-141's surface is genuinely new and doesn't belong inside an atomicity regression test.
Pre-check coach reservations in Pass 1 alongside the booking-conflict check. The route calls `public.check_booking_against_coach_reservations(p_check_kind='court_booking', p_coach_id=NULL, …)` per candidate occurrence and treats the raise of `court_reserved_for_coach` as a skip with `reason: 'coach_reserved'`. Distinct reason in `skipped_occurrences` so the UI can say 'court reserved for a coach' instead of a generic conflict.
Alternatives considered
Calling the existing PG function via RPC lets us reuse the trigger's exact semantics without duplicating SQL in JS. The function raises predictably; the route's try/catch maps that one error code to one skip reason.
Chips win. The clicked slot supplies the time-of-day for every occurrence and the default Start Date — its DoW is purely a chip pre-selection hint that the operator can deselect. Click Tue 5 PM, pick [Wed] → batch creates Wednesdays only, no Tuesday.
Alternatives considered
Mental model is cleanest when chips are the source of truth and the click slot is purely 'I needed somewhere to start'. Matches the Booky/Playtomic reference UI the boss attached.
`skipped_occurrences: Array<{date: 'YYYY-MM-DD', day_of_week: 0-6, reason: 'conflict' | 'coach_reserved' | 'error'}>`. `skipped_weeks` is removed (DEC-198 hard break, no deprecation window). UI renders skipped dates with their reason ('Wed May 27 — court reserved for a coach'). `created_count`, `total_requested`, and `bookings` remain.
Alternatives considered
Per-occurrence objects encode the date, the DoW, and the reason in one record — exactly what the UI needs to render a useful skipped-dates toast. No translation layer required.
Chips only in this PR. The dropdown is pure UI sugar — picking 'Weekdays' just toggles five chips. Presets can ship in a follow-up without an API change.
Alternatives considered
One feature per PR. Presets cost ~half a day of UI work whenever they're prioritized.
Replace `recurring_weeks` entirely. New canonical fields: `recurrence_end_date` (YYYY-MM-DD, facility-local) + `recurrence_days_of_week` (number[] of 0-6). The route hard-rejects any request carrying `recurring_weeks` with `error: 'recurring_weeks_removed'`. The dialog's two callers (calendar-grid, week-calendar-grid) and the e2e tests (TC-075, TC-078, TC-116, TC-103) are migrated in the same PR.
Alternatives considered
Single canonical shape. No drift, no autocomplete noise, no future migration. The all-or-nothing reject also catches automation scripts that might be sending the old field.
90-day soft cap (UI default + browser-enforced `max` attribute on the End Date input), 180-day hard cap (route rejects with `recurrence_exceeds_max`). Operators get the documented '90 days max' guidance but the API gives one safety budget if they really need a 6-month season.
Alternatives considered
UI tells operators '90 days max' (matches the reference image); the route adds a 180-day safety budget for genuine outliers. Cap mismatch is intentional — UI guidance is softer than API enforcement.
Rename `coach_schedules` → `coach_availabilities`, DROP `day_of_week` entirely, and move weekly data into `recurrence_config.days_of_week` via in-migration backfill. `is_available` was already dropped in A5 — the new exceptions table is the supported way to express unavailability. Single source of truth (the trigger + helper functions read `recurrence_config`, not the old column) and the table name finally matches what operators call it.
Alternatives considered
Schema-correct now > minimal diff later. The helper functions, the overlap trigger, the booking trigger, and the editor all read the same `recurrence_config` regardless of type. The seed and existing weekly rows are backfilled losslessly in the same migration. The COALESCE'd shape CHECK constraint rejects malformed configs at insert time (a CHECK that evaluates to NULL passes by default — wrapping the CASE in COALESCE(... , false) forces unknown branches to fail).
Per-type bounds. `coach_availability_dates(rec_type, rec_config)` materializes exactly the row's occurrences: 1 date for single_date, generate_series(start_date, end_date) for date_range, and the 12-month forward cap only for the genuinely unbounded weekly/monthly types.
Alternatives considered
Bounded types (single_date / date_range) get exact materialization, so the false-positive risk at the 12-month edge disappears for those cases. Weekly/monthly still need a cap (heuristic — a 13-month-out conflict wouldn't be caught) but that's the same trade-off A6 made, applied only where needed.
Both. The CHECK constraint enforces (start_time AND end_time both NULL) OR (start_time AND end_time both set with end > start). The trigger function branches: whole-day exceptions zero out the coach's applicable multirange for that date; time-windowed exceptions are aggregated via range_agg and subtracted from the applicable multirange. The booking trigger uses both paths against (coach, facility, date) so a customer booking 14–16 is allowed only if either a whole-day exception covers the date OR a time-windowed exception fully contains 14–16.
Alternatives considered
The two shapes are equally cheap to express schema-side (one CHECK constraint) and the trigger logic is the same multirange-subtraction code regardless. Editor UX uses a 'Whole day' checkbox to toggle between the two — defaults to true so the simple case is one-click.
Compute at query time. Each of the three routes already builds per-customer aggregates in JS over fetched rows (lastVisitMap, bookingCountMap, spendMap); extending those reducers with two more sources (pos_orders.created_at, facility_tickets.closed_at|opened_at) is a few lines per route with no new write-path complexity. `facility_customers.id → facility_customers.profile_id` is the existing join for the POS side; the customer-detail route already uses this exact pattern. Don't add a `customers.last_visit_at` column, don't add triggers, don't backfill.
Alternatives considered
Self-debate (round 1 → round 2): round 1 proposed the denormalized column for 'always-fast reads' before re-checking the customer-count scale (~3000) and the read-path's existing pattern (per-route JS reducers). Round 2 caught that the column path moves complexity from a slow read (which doesn't exist at this scale) to a fast write (which exists everywhere — every order line, every ticket open/close — and adds race-handling). Per CLAUDE.md 'Built-in first': the route's existing reducer pattern IS the convention for these aggregates (bookings count, total spend, tag map, membership map all live there) — extending it for two more sources is the smallest delta that respects the pattern. The fix is purely on 'last visit' semantics; bookings_count stays bookings-only because its name is correct. Three CRM API surfaces aligned in one PR so segment filters on last_visit_days don't drift from the list/detail definition.
Fix here. Rename the SELECT to `price_amount` everywhere in the three CRM routes already being touched for E1. The customer-detail route's response keeps the `price_cents` field name in the JSON (API contract) — only the SQL column reference + internal usages move. This is a one-line-per-spot change that makes the E1 'no regression for bookings' DoD actually hold (otherwise the path was vacuously 'not regressed' because it was already broken). Out of scope (separate PR): the same typo in /api/admin/crm/customers/[id]/timeline (separate route) and crm/customers/[id]/page.tsx (UI render — the page reads the API's `price_cents` field, which the API still returns under that name).
Alternatives considered
Per CLAUDE.md 'No silent errors (STRONG BIAS)': PostgREST's 400 falling through to `data: null` with no warning is exactly the anti-pattern the project deplores. The right long-term move is to flip the `(supabase as any)` cast off and let TypeScript catch column typos at compile time (the regenerated database.types.ts knows the column is `price_amount`), but that's its own cleanup pass (see Known Issues: ``(supabase as any)`` casts to clean up). For this PR, fix the typo in the routes already being touched and document the broader cleanup as out-of-scope. Pre-production boldness applies here — the typo bug was the *primary* cause of Alba's symptom (booking-based last_visit never working), and shipping E1 without fixing it would mean Alba's profile shows her latest POS order timestamp while pretending bookings contribute (they don't, because of the typo).
Keep the existing payment_status CHECK constraint as-is (pending|paid|refunded|partial_refund). The fix is two-part: (a) create_lesson_atomic flips bookings.payment_status to 'paid' inside the same transaction whenever payment_method='package' and consumption has succeeded — package-paid bookings ARE fully paid; the only differentiator is the *method*, and payment_method already captures that; (b) the badge UI on booking-detail-dialog.tsx and bookings-list.tsx derives the new 'Paid with Package' label from (payment_status='paid' AND payment_method='package'). Existing 'Paid'/'Refunded'/'Partially Refunded' variants are unchanged.
Alternatives considered
Self-debate (round 1 → round 2): round 1 proposed deriving from (status, method) and called it done. Round 2 stress-tested by imagining adding 'paid_with_credits' / 'paid_with_external' next — the enum-extension path required a migration each time, the derivation path didn't. The derivation is also better for refund flow consistency: a package-paid booking that gets refunded still goes payment_status='refunded' (or 'partial_refund') without a separate 'refunded_from_package' state. The badge becomes a small JSX condition rather than a database concern.
Editor switches from a single Scope-target dropdown to a list-of-rows pattern (modeled on coach-schedule-editor.tsx): row 1 defaults to 'Anything in this domain' as today; an 'Add target' button below the list appends a new row defaulting to the first non-`any` option for the current scope_domain (so the operator's intent for granular targeting is reflected immediately, avoiding a jarring 'any twice' default). Each row has a Trash icon to remove. A soft amber warning surfaces when 'any' is mixed with specifics (since `any` makes the others redundant), but save is not blocked — operators may have legitimate reasons (e.g. authoring partial rules, intermediate state). Empty targets[] is rejected at both UI and API layers (400) instead of silently defaulting to `any`, since a rule with zero targets applies to nothing and the silent default hides operator error. Rules-list page gains type-labeled target chips so multi-target rules are visible without opening the editor (full name resolution would need an extra fetch and is deferred). The API routes (POST + PUT /api/admin/crm/discount-rules) already accept and persist targets[] of any length — only the empty-array check was new.
Alternatives considered
Two rounds of self-debate. Round 1 picked list-of-rows + soft warning + non-empty validation. Round 2 caught the new-row default question: defaulting to 'any' on Add target produces the weird 'any twice' state, while defaulting to the first non-`any` reflects operator intent (they clicked Add target precisely BECAUSE they wanted granular control). Also surfaced the empty-array silent-default anti-pattern in the existing API code, which the PR removes. Pattern reference: apps/admin/src/components/admin/coach-schedule-editor.tsx (per-row Trash button, 'Add' affordance below the list).
Fix in this PR. New migration supabase/migrations/20260511054837_fix_resolve_discounts_target_dedup.sql replaces the JOIN with EXISTS: `WHERE ... AND EXISTS (SELECT 1 FROM crm_discount_rule_targets t WHERE t.rule_id = r.id AND <target match conditions>)`. The outer query no longer multiplies by t, so each rule appears at most once. EXISTS short-circuits at the first matching target so 'OR across targets' semantics are preserved. Function signature and return shape unchanged — all callers continue to work. Idempotent CREATE OR REPLACE FUNCTION; safe to re-apply. For single-target rules (every existing prod rule before this PR) the output is byte-for-byte identical.
Alternatives considered
Per CLAUDE.md 'Architecture-first, not patches (STRONG BIAS)': fix the root cause, not the symptom. EXISTS is the semantically correct way to express the eligibility check ('rule is eligible if AT LEAST ONE target matches'); the original JOIN conflated 'is the rule eligible?' with 'how many targets did it match?'. The prompt's hint about 'add DISTINCT or GROUP BY r.id' confirmed a fix in this PR was acceptable. Migration is a single CREATE OR REPLACE FUNCTION, fully idempotent, deploy via the standard `supabase db query --linked --file ...` path documented in CLAUDE.md.
Enforce per-court coach reservation at the DB-trigger layer with strict-Calendly semantics. Schema: coach_schedules gains court_id NOT NULL REFERENCES courts(id), drops is_available (absence of a row = unavailable), gains a generated time_range numrange column, and a no_court_overlap EXCLUDE constraint (no two coaches reserve the same court+day+time-range). A shared SQL function check_booking_against_coach_reservations is wired into three triggers: bookings (BEFORE INSERT/UPDATE — rejects court/match bookings overlapping any coach's reservation on that court+day; lesson-typed bookings skip and rely on lesson_courts), lesson_courts (BEFORE INSERT/UPDATE — strict-Calendly: range_agg(coach reservations on this court+day_of_week) must @> the lesson's time range), and lessons (AFTER UPDATE OF coach_id/start_time/end_time/status — re-runs the lesson_courts validation when the lesson's window shifts). Errors raise SQLSTATE P0001 with a JSON DETAIL payload; apps/admin/src/lib/translate-coach-error.ts translates them into operator-readable messages on bookings, lessons, hold, and the schedule editor. The migration also rewrites hold_lesson_slot and create_lesson_with_bookings to set booking_type='lesson' on the bookings they insert (otherwise the new bookings trigger correctly rejects those holds as court_reserved_for_coach for any coach reserved on the booked court).
Alternatives considered
Three rounds of self-debate before locking the recommendation (process documented in coach-court-reservation-context.md). Round 1 produced a 'flexible' lesson check; round 2 caught that strict was correct after the user objected and the industry-standard check (Calendly/Acuity/Mindbody/Booksy all reject out-of-window scheduling) was applied. Round 3 caught that no_coach_overlap was over-enforcing and would break multi-court group lessons. The migration also covers the secondary blast-radius: hold_lesson_slot / create_lesson_with_bookings were inserting bookings with default booking_type='court', which the new bookings trigger correctly rejects as a customer booking on a coach-reserved court. The fix sets booking_type='lesson' inline so those holds skip the trigger as intended.
Architectural rework. Add `crm_discount_rules.is_auto_mirrored boolean NOT NULL DEFAULT false` as the structural marker, replacing every `name LIKE 'auto:%'` reference (unique partial index, both auto-mirror trigger functions, all consumer code). Add SQL helper `public.credit_tier_label(slug text) RETURNS text` (IMMUTABLE, STRICT) as the single source of truth for the four-tier slug→label map; the credit-tier sync trigger calls it. Backfill in the same migration: flip is_auto_mirrored for rows whose actor_id matches a real source row (rigorous predicate, not a blind name-prefix UPDATE), strip the legacy 'auto: ' prefix from name, rewrite credit-tier names to use credit_tier_label(tier_name), replace internal 'Auto-mirrored from <table>.<column>' boilerplate descriptions with operator-facing UX copy ('Mirrored from Loyalty → Memberships' / '→ Wallet'). Drop the prior partial unique index keyed on `name LIKE 'auto:%'` and create a new one keyed on `is_auto_mirrored = true`. UI deletes its TIER_LABEL map + prettifySnake + isAutoRule + formatRuleName + formatRuleDescription helpers and renders r.name / r.description directly; the auto badge reads from r.is_auto_mirrored.
Alternatives considered
Stress-tested the recommendation against five counter-arguments before landing it (logged in PR description). The two that landed and reshaped the plan: (1) 'a `tier_label` column is over-engineering for a 4-value enum' — replaced with a SQL helper function, narrower diff and no schema-width cost; (2) 'shared TS module duplicates rather than dedupes since triggers can't import TS' — dropped the TS shared module, kept slug→label only in SQL. The three that didn't land: 'is_auto_mirrored is redundant with actor_type' (false — manual subscription/tier rules can coexist with auto-mirrored ones, the existing partial unique index proves this), 'JOIN at read time would be cleaner than mirroring' (out of scope, see alternative #3), 'engineering ROI is too high for a cosmetic snake_case fix' (overruled by CLAUDE.md's pre-production boldness rule and the compounding cost of leaving the magic-prefix pattern in place for future auto-derived rule types). Backfill predicate is intentionally rigorous (`AND EXISTS` against the source tables) so a rule that happens to have name LIKE 'auto:%' but points at a customer_group or a deleted plan does not get the flag flipped. After this migration, every reference to 'auto:%' is gone from the codebase except the migration itself, which uses it once for backfill detection.
Land the engine + admin only — pure foundation, no POS/Terminal consumer code. In scope: the SQL foundation (5 new crm_* tables, polymorphic actor validation trigger, resolve_discounts() + commit_discount_applications() functions, calculate_member_price refactor delegating to the resolver, auto-mirror triggers on membership_plans + credit_tier_configs, ALTER pos_orders + pos_order_items for the locked-in Deliverable B columns, RLS), the admin API surface (15 endpoints for groups + discount-rules CRUD + members + preview + applications + customer-detail discounts), the admin UI (sidebar + /crm/groups + /crm/discount-rules + customer-detail panel), and the admin PMS booking-route wiring (resolve + commit post-insert) which serves as the canonical consumer pattern. The pos_orders.discount_kind / discount_note + pos_order_items.is_foc / foc_reason columns ship as the locked-in contract for the consumer. Defer: POS/Terminal consumer wiring (BUG-082, delegated to POS/Terminal dev — see plan-pos-terminal-discount-integration.md), OpenRally protocol bump (BUG-073, boss decision pending TPS-Setpoint integration go-live), Programs/Open-matches surface wiring (features nav-hidden today; 'program' reserved in scope_domain enum), Lesson + program consumer wiring (BUG-074), legacy POS group-stack retirement (BUG-075, POS-dev-owned), Deliverable B cashier-typed UI + per-line FOC affordance (UX design pending).
Alternatives considered
Splitting along the natural fault lines (engine + admin here; POS/Terminal + OpenRally + cashier UX as separate follow-ups owned by the right authors) keeps this PR reviewable by the boss in one sitting and keeps the human-ownership boundaries clean. The resolver + ledger means no future PR has to re-architect; consumer integrations become small additive PRs by the apps' real authors against this stable contract. The locked-in pos_orders columns + the @setpoint/common/discounts subpath export are the contract; the admin booking route is the pattern; that's all the next consumer needs. Pinned by 17 SQL eval cells in supabase/snippets/discount_engine_evals.sql (E17 added during PR self-review to pin the multi-group parallel-array bug fix) + TC-130 e2e. Naming-convention note (post-deploy, 2026-05-07): all 5 new tables shipped with `crm_*` prefixes for migration tidiness, but only `crm_customer_groups` + `crm_customer_group_members` are genuinely CRM-scoped (configured in /crm/groups, distinguished from legacy pos_customer_groups). The other three — `crm_discount_rules`, `crm_discount_rule_targets`, `crm_discount_applications` — are engine-scoped, not CRM-scoped: they back every channel (booking, lesson, program, POS, terminal, OpenRally). Renaming the engine-scoped tables after prod deploy isn't worth the churn (eval suite re-validation + consumer code re-ping + migration babysit). FUTURE engine-scoped tables MUST NOT use the `crm_` prefix; reserve `crm_*` for tables with the same configuration-side scope as the original groups tables (e.g. `crm_customer_segments`, `crm_customer_tags`). Use `discount_*` or no prefix for engine-scoped tables (`discount_<thing>`, `<engine_concept>`).
Render two independent stacked Card sections — Memberships (top) and Wallets (bottom). No tabs. The Memberships section holds subscription Plans configuration plus a Pending Payments sub-list (orphan user_memberships awaiting Stripe Checkout); its CardHeader carries the Assign Membership entry point. The Wallets section holds tier configuration (tier-config-tab no longer wraps its own Card; the parent Wallets card provides the chrome). Per-customer browsing is delegated to CRM Customers (`/crm/customers` and the detail page), which already exposes membership, wallet balance, and tier per customer. The unified-members-list.tsx component is deleted with no compat shim.
Alternatives considered
Two independent stacked sections matches the operator's mental model: Memberships and Wallets are genuinely two different loyalty programs (recurring vs accumulated). Keeping the orphan-payment queue inside Memberships keeps the operations action right next to the configuration that produces it. Delegating per-customer browsing to CRM removes the duplication that the unified list created. The Card-with-CardHeader-CardContent shape lifts directly from operations-settings-tab.tsx and facility-settings-form.tsx — zero new UI primitives.
Drop all four columns from credit_tier_configs and rewrite calculate_credit_tier to no longer return them. Tiers are now exactly threshold + court_discount_pct — symmetric to how subscription plans expose price + discount_percentage. Operators on either side configure 'qualify at X, discount Y%' and that's it.
Alternatives considered
Tiers and subscription plans now have isomorphic shapes: threshold/price + court_discount_pct/discount_percentage. The better-of pricing rule (DEC-183) compares these directly. Removing the stub fields kills 4 sources of operator confusion (BUG-036, gaps #2/#5/#7 in arch-discounts-snapshot, and the demo's wrap-around label issue). Closes BUG-036 by removing the column rather than wiring enforcement.
Use a 'better-of' rule. Compute the subscription discount % (from active user_memberships row's plan.discount_percentage) and the credit-tier court_discount_pct independently. If both apply, take GREATEST(sub_pct, tier_pct). If only one applies, use that. If neither applies, no discount. The membership_model JSON key in facilities.settings is no longer read by the pricing engine and is no longer exposed by /api/v1/facilities/{slug}/membership-plans or /api/admin/credit-tiers. Pricing-rule member_price_amount remains a hard per-slot override (operator's exact 'member rate' for a slot) ahead of either percentage path. tier_name surfaces only when the tier path wins (or ties) — never when subscription wins outright.
Alternatives considered
Better-of is the simplest rule customers and operators can both reason about: 'we honour your better discount.' It's also the smallest possible change to the SQL — one GREATEST() call instead of an if/else dispatch. The toggle deletion is a clean break: pre-production, no external consumers, the 'membership-model-select' testid was the only reference and it's gone. The membership_model JSONB key is left in existing rows (no backfill needed) since nothing reads it.
Rename ONLY the operator-visible surface — sidebar nav label, page H1, route path (/memberships deleted, /loyalty added — clean break, no redirect), and the 'Membership Model' settings card → 'Loyalty Model'. Leave DB tables (user_memberships, membership_plans), API routes (/api/admin/membership-plans, /api/v1/memberships/subscribe), the membership_model JSON key in facilities.settings, the 'membership-model-select' data-testid, and component file names (memberships-page-client.tsx etc.) untouched.
Alternatives considered
Operator-facing rename is high-clarity, low-cost. Schema/API/testid/component-name churn would be high-cost, low-clarity (zero operator visibility, breaking-change risk). The settings JSON key 'membership_model' stays the same so existing facility records don't need a backfill. Future agents reading the codebase will see 'Loyalty' in the UI and 'membership_*' under the hood — the DEC entry is the bridge that explains why those are intentionally divergent.
Tie every credit add to a paid payment row and let a DB trigger mint the wallet transaction(s). Cash and Stripe both flow through payments.status pending → succeeded for type='credit_topup'; the grant_credits_on_topup_payment trigger maps payment_method (stripe → customer_payment, cash → admin) and calls record_credit_transaction. Bonus credits get a second transaction with source='system' on the same payment_id. Six transaction types collapse to three (topup, booking_payment, booking_refund); is_topup is dropped (subsumed by type='topup'); a CHECK invariant enforces topup⇒payment_id-and-no-reference_id and booking_*⇒reference_id-and-no-payment_id. record_credit_transaction is the single write RPC and raises insufficient_balance with structured DETAIL on overdraft instead of clamping to 0. Trigger idempotency guarded by WHEN (NEW.status = 'succeeded' AND OLD.status IS DISTINCT FROM 'succeeded') so Stripe redeliveries don't double-credit.
Alternatives considered
One paid payment = one credit-grant event. The ledger now has a strict invariant (topup ⇔ payment_id, booking_* ⇔ reference_id) that the schema enforces, so future drift is impossible at the data level. The trigger pattern is reusable: any future credit_topup-typed payment (offline ACH, package promo, etc.) will mint credits on the same status flip without touching application code. The TRUNCATE-and-rebuild migration is acceptable because we're preprod with no real wallet data; once production has wallet rows, equivalent reshapes will need a backfill plan.
Keep both. Document the deviation in the migration header and in this decision. Update wallet_evals.sql so the schema-shape assertion no longer requires either column to be dropped. Loyalty PR (sister scope) can revisit memberships rename and decide on stripe_price_id afresh; tier-based pricing's relationship to booking_window_days is a different conversation that doesn't belong in a wallet PR.
Alternatives considered
Plans drift between writing and execution. The wallet plan was authored before the audit was re-run; both columns had grown new consumers since. Verifying with grep before deleting is cheap (seconds) and the cost of wrongly deleting either was a 5+ file regression. Documenting the deviation rather than silently keeping the columns means the next agent reading this PR sees the audit was corrected, not skipped.
One mount, on the CRM customer detail page, in the overview tab between the stat cards and Membership. Strip the side-panel mount, point TC-117 at the CRM page. The CRM page already houses notes, tags, bookings, ratings, KPIs — wallet belongs there too, not buried in a side-panel that only opens after a row click.
Alternatives considered
The wallet section sits at the same nesting level as Membership — same visual weight, same tab. The side-panel removal also lets us delete the email-based /api/admin/club-credits balance lookup from the panel and replace it with the cleaner /api/admin/wallet/customer-balance endpoint scoped by user_id (the CRM URL is already keyed on user_id, so the lookup is naturally scoped that way).
Batch in TypeScript, reuse the existing per-row RPC unchanged. The route splits into two passes: Pass 1 conflict-checks every week and builds a viable-weeks list; Pass 2 calls create_booking_with_credits once per viable week. Before Pass 2 starts, the route pre-checks the customer's balance against `viable.length × price` and returns HTTP 400 if they can't afford the full set, so we never enter a partial-debit state. A per-week RPC failure (rare race) skips that week with no orphan ledger row, mirroring how the existing loop already handles per-week INSERT errors.
Alternatives considered
The existing per-row RPC has exactly the right contract — one atomic insert+deduct with reference_id linked. Batching in TS keeps the conflict check, the partial-success semantics, and the post-insert side-effect block (booking_players, cost_split) all in the route handler where they already live. The SQL surface stays small (no new function, no new migration), and the recurring-loop control flow stays readable. Trade-off: N round-trips per recurring booking instead of 1 batch call. Acceptable — N ≤ 52 (the cap) and recurring bookings are operator-driven, not high-throughput.
Add a new wrapper RPC public.create_booking_with_credits that does INSERT INTO bookings RETURNING id then calls adjust_club_credits(p_reference_id := <new booking id>, ...) inside one implicit transaction. The route handler delegates to the new RPC for single-booking + payment_method=credits + price>0 only; everything else (non-credits, free-on-credits, recurring) uses the direct insert path unchanged. adjust_club_credits is not touched. Validation of sufficient balance is done in the wrapper (RAISE EXCEPTION 'Insufficient club credits...') because adjust_club_credits clamps negative results to 0 — relying on it for sufficiency would silently under-deduct.
Alternatives considered
Wrapping isolates the atomicity guarantee to the booking-create path without touching code shared by refunds and top-ups. The pattern is cheap to extend later: the same wrapping shape can wrap the recurring loop (BUG-039) or future flows that combine a write with a credit movement. Sufficient-balance validation in the wrapper is a deliberate choice — adjust_club_credits' GREATEST(0, balance + amount) clamp is the right contract for refund / admin_deduct callers but the wrong one for booking pay, so the wrapper enforces the stricter invariant before the deduction.
Strict mirror of the court per-player flow, deviating only where the lesson data model forces it. Four sub-decisions: (1) Sibling function checkInLessonStudent next to checkInPlayer in packages/common/src/check-in.ts — same shape, same return type, reuses derivePricing/resolveFacilityCustomer/buildBookingLineItem helpers. (2) New admin endpoint PATCH /api/admin/lessons/[id]/students keyed by lesson_id (not booking_id) — multi-court lessons would otherwise be ambiguous on /bookings/[id]/.... (3) Per-student tickets attach to the primary lesson_courts.booking_id (is_primary=true); the 'all in' flip spans every booking row linked to the lesson via lesson_courts. (4) Pricing reads each student's lesson_players.payment_method × class.price_per_pax_amount — pending students get pricePerPax on their own ticket so the till can collect from them individually. Court's 'organizer pays the booking_price, everyone else 0' model does NOT apply (lessons charge per-student per DEC-147–149). Schema gets one new column: lesson_players.checked_in_at. Tickets do NOT get a lesson_player_id FK — match court's existing ticket_name match pattern; a unified participant_id is tracked as followup #1.
Alternatives considered
Builds on DEC-173/174 — pattern is established, reviewers can diff line-by-line against checkInPlayer, and improvements (race fix, unified participant_id, court split-pricing wiring) live in apps/docs/src/data/followups-checkin.md as tracked items. The pricing divergence from court is honest: lessons fundamentally have per-student payment_method (DEC-147–149), so the 'organizer pays for everyone' model doesn't translate. Stable ordering (primary first, then by email) was added in both surfaces' enrichment after a TC-114 flake exposed how PostgREST's undefined order broke positional UI selectors.
One unified Type column (Court / Coaching / Event badge via the existing BookingTypeBadge) replaces Coach + Match. Created On becomes the leftmost sortable column AND the default sort (created_at desc), so the most recently booked row sits on top. Date/Time column renamed to Booking Time for clarity. Add to Calendar button removed from the booking detail dialog and its orphaned lib/ics.ts deleted. Existing Court/Coaching chip filter preserved as-is. Date range filter inputs widened from w-36 to w-48 so the native picker content no longer overflows. No tabs, no separate routes, no schema change.
Alternatives considered
Boss wanted two things: operators finding their just-booked row in one glance (solved by created_at default sort), and tighter columns that don't waste horizontal space (solved by Type column replacing Coach + Match). Reusing BookingTypeBadge keeps the labeling consistent with the existing chip filter. Widening the date inputs fixes a real overflow bug the boss saw in their browser. All four changes shipped in one UI-only PR; no DB, API, edge function, or OpenRally changes. Pre-existing quirk noted but out of scope: hasFilters in bookings-list omits typeFilter, so the Clear button doesn't appear when only a type chip is active — file a one-line follow-up if that becomes user-visible.
Shared TypeScript module at packages/common/src/check-in.ts with two exported functions (checkInBooking, checkInPlayer). Both admin (PATCH /api/admin/bookings + PATCH /api/admin/bookings/[id]/players) and terminal (useCheckInBooking + useCheckInPlayer hooks) import and call these functions. The module is pure: it takes a Supabase client + a findOrCreateProfile(email, name) injection as parameters, so each surface passes its own auth context (admin → service-role client + findOrCreateUserByEmail; terminal → user-JWT client + findOrCreateGuestAccount server action). Results are discriminated unions (`{ ok: true, ... } | { ok: false, code, message }`); admin maps ok/false codes to NextResponse status codes, terminal re-throws so react-query's onError fires exactly as it did before. The module also unifies three safety checks that existed only on admin: full-booking idempotency (via ticketMode: 'idempotentIfExists'), per-player ALREADY_CHECKED_IN precheck, and full rollback on per-player ticket failure (reverts booking_players.checked_in_at AND bookings.checked_in when the latter was flipped by this check-in). Terminal gains all three as strict safety improvements — no happy-path UI changes.
Alternatives considered
Both surfaces already run TypeScript in the same Turborepo monorepo. A workspace package is the lowest-ceremony option — no new runtime, no new deploy target, no Supabase functions config, no env-var plumbing in supabase/config.toml. The injected-hook pattern cleanly separates business logic from auth context, which is the one real difference between the two surfaces. Supersedes DEC-173 (the inline-port trade-off).
Full parity with terminal, ported inline into admin (server + UI). Two endpoints: (1) PATCH /api/admin/bookings with checked_in=true mirrors useCheckInBooking — resolves facility_customer via profile RPC → email lookup with auth-account creation (findOrCreateUserByEmail) → fallback profile-less insert; allocates ticket number; writes facility_tickets with a populated cart_json booking line item (isBooking, isPrePaid/isUnpaid from payment_method, bookingRef, bookingDisplayPriceCents); idempotent. (2) PATCH /api/admin/bookings/[id]/players with {player_id, checked_in:true} mirrors useCheckInPlayer — sets booking_players.checked_in_at, creates a per-player ticket (organizer pays the court fee line, non-organizers get ₿0), flips bookings.checked_in=true only once every player is checked in; rolls back on failure. UI matches terminal: per-player rows show a Check-In button OR 'checked at HH:MM' + ticket number; booking-level button becomes 'Check In All (N)' when players.length > 0 and is hidden once everyone is in. enrich-bookings.ts now returns checked_in_at + ticket_number per player (cross-referenced to facility_tickets by ticket_name). All ticket notes / times use the facility timezone.
Alternatives considered
One-PR parity so admin and terminal produce identical DB state (bookings, booking_players, facility_customers, facility_tickets) regardless of surface. Tech debt is acknowledged — two implementations of the same business logic now exist; a follow-up PR should extract to a shared helper (Postgres RPC for DB work + thin wrapper for Auth-admin calls, or Edge Function for full unification). Until then, both admin handlers carry a header comment pointing at apps/terminal/src/mutations/use-pms-booking-mutations.ts so drift is visible. Handoff document: admin-checkin-parity-handoff.md at workspace root.
Organizer pays in full. The lesson's primary player (is_primary=true) becomes the payer; a single Stripe Checkout Session is created for price_per_pax × player_count. When the webhook fires, every lesson_players row on that lesson is marked paid with its per-pax share so reporting rolls up correctly. The multi-player radio picker in the admin UI is removed — admin clicks Get Payment Link once per lesson regardless of player count. No data-model changes; only endpoint, UI, and webhook logic simplified.
Alternatives considered
Matches court bookings exactly (DEC-152) — one link, one payment, organizer-pays-in-full. Removes ~50 lines of code (picker UI, per-player metadata plumbing, multi-player endpoint branch). Cleaner base to layer cost-splitting on top when DEC-152 ships. No regressions for the demo or single-player (private) lessons. Multi-player lessons still work — they're just charged once to the organizer's card.
Decouple. OpenRally materializer emits atomic slots at a fixed 30-min stride regardless of pricing rules. Pricing rules continue to define bookable durations (60/90/120 etc.). Availability endpoint accepts a requested duration only when (duration % 30 == 0) AND (duration >= min pricing-rule duration).
Alternatives considered
Industry-standard booking platforms (Playtomic, Calendly, Google Calendar, Cal.com) separate display/start-time granularity from bookable durations. Users should be able to start a booking at 07:30 without the facility needing to 'offer' a 30-min product. Pricing rules remain the source of truth for *what durations* are sellable and at *what price*; the protocol layer owns *how finely* slots are materialized. This supersedes the 2026-04-16 decision ('facility's minimum pricing rule duration = slot stride'), which conflated the two concepts.
Admin day and week grids render 30-min ticks with hour labels only on :00 rows and a lighter gridline at :30 (Google Calendar pattern). Duration chips in create/edit booking dialogs are 60/90/120 min (was 60/120/180).
Alternatives considered
Matches the TPS mobile app's start-time granularity so an admin seeing a booking in Setpoint sees the same time representation that players see in TPS. Dropping the 3-hour option removes a duration that no mobile consumer offers, keeping the two surfaces in lockstep. Visual density unchanged for 60-min bookings (span of 2 × 48px = 96px, same as old 1 × 96px cell).
Admin-only across three surfaces (court bookings, lessons, memberships). Public-web Memberships stays hidden. The admin flow closes the collection gap for BUG-019's descendant case — admin-assigned unpaid memberships — plus extends to lesson and court bookings on boss's direction so the same collection primitive covers every admin-side revenue surface.
Alternatives considered
Mirrors the existing booking pattern (apps/admin/src/app/api/admin/bookings/[id]/payment-link/route.ts, shipped) and matches the CEO directive of 'internal operations first, public APIs consumed by user-facing apps at the end'. Admin-only keeps the surface small and the permission model clear (requireFacilityAccess).
Stripe Checkout hosted URL. All Checkout Sessions use session.url verbatim — no custom page, no embedded Payment Element.
Alternatives considered
This IS the industry-standard pattern for hosted-checkout share flows (Shopify, Linear, Vercel, Notion, Stripe's own Payment Links product). Stripe Checkout URLs are mobile-responsive, support Apple Pay / Google Pay / 3D Secure, expire after 24h, and keep PCI scope fully inside Stripe. Building custom would be a strict downgrade.
Two separate buttons rendered side by side. 'Get Payment Link' = existing copy flow (POST, shows read-only URL + Copy button). 'Send via Email' = reveals an inline recipient-confirmation panel showing 'Send to alice@example.com' with Confirm + Cancel; on confirm, POSTs the sibling /email endpoint, sends via Resend, toasts success, AND displays the generated URL so the admin has a copy.
Alternatives considered
Two buttons keeps each action discoverable and independently testable. Inline confirmation for the destructive-ish Send action is the common pattern for admin tools that email third parties. Showing the URL alongside the toast after send means the admin always has a copy (boss's explicit request).
Separate /email sibling routes per surface. Four pairs total: bookings/[id]/payment-link + /email, lessons/[id]/payment-link + /email, user-memberships/[id]/payment-link + /email, memberships/payment-link + /email. Each route is stateless — it creates a fresh Stripe Checkout Session on every call; the /email variant also resolves the recipient server-side, invokes the edge function, and returns { checkout_url, session_id, sent_to }.
Alternatives considered
Clean REST shape — one resource, two distinct verbs, both stateless. Mirrors the shape of the existing bookings/[id]/payment-link endpoint. Each endpoint is independently rate-limitable, cacheable, and testable. Client always gets the URL back on every call (including email sends) so the admin 'has a copy' per DEC-165.
Three granular edge functions: send-booking-payment-link, send-lesson-payment-link, send-membership-payment-link. Each is self-contained — it renders its surface-specific HTML + plain-text template, calls Resend, and returns 200/5xx. A small inline helper for the Resend fetch + HTML shell is duplicated across the three files (not extracted to a shared package) to keep each function independently deployable.
Alternatives considered
Matches the granular-function pattern already established. Each template evolves independently (membership plan name formatting ≠ lesson coach+class formatting ≠ court+time formatting). Greppable: searching for send-lesson-payment-link finds every touchpoint. Edge function isolation means a template bug in one surface doesn't break the other two.
notifications@omnigpt.co (same domain as send-booking-confirmation), with display name = facility name. Single SPF/DKIM/DMARC surface across all transactional email — deliverability stays consistent as volume grows.
Alternatives considered
One domain to warm up, one DMARC policy, one place to debug bounces. Per-facility branding is already conveyed by display name + HTML header; the envelope sender is an operational concern.
Server-side resolution per surface: Booking → bookings.guest_email → fallback auth email via user_id. Lesson → the player selected in the inline picker (auto for single-player lessons). User-membership row → auth email via user_memberships.user_id. Plans-tab typeahead → auth email of the picked user. If no email resolvable, the 'Send via Email' button renders disabled with tooltip 'No email on file'; the 'Get Payment Link' (copy) button stays enabled so admin can still hand-share via WhatsApp.
Alternatives considered
Server-side resolution keeps the client simple and the audit trail tight: the admin never handles email strings. Per-player picker on multi-player lessons reflects the reality that lessons have per-pax pricing. Disabled-with-tooltip is the Shopify Polaris pattern for conditional affordances.
No persistence. Each POST creates a fresh Stripe Checkout Session. Stateless, idempotent-per-call, zero schema migration. Stripe Checkout Sessions expire after 24h anyway — persisting a URL would routinely surface stale links.
Alternatives considered
Stripe Sessions are cheap. 'Fresh on every click' is the simplest contract — no staleness, no migration, no invalidation logic. Matches how the shipped bookings/[id]/payment-link already works.
v0.21.2 patch. Builds directly on the v0.21.1 webhook-hardening (orphan adoption is the exact path exercised by admin-assigned memberships paying via this new flow). v0.22.0 is allocated to Unified Calendar and v0.23.0 to OpenRally Payment Sync; injecting v0.24.0 for a small cross-surface UI addition would mis-advertise scope.
Alternatives considered
Patch semantics fit: no breaking change, no schema change, depends on v0.21.1's error-handling fix. Minor bumps are reserved for cross-cutting user-facing releases.
Add an optional `payment` block to POST /openrally/v1/bookings (echoed in all booking responses + SSE/webhook event payloads). Three required fields (status / method_type / external_ref) plus optional amount/currency. Vendor-neutral enums (`paid|pending|partial` and `prepaid|pay_at_venue|comp|credit|other`); consumer-specific detail (Stripe PI, PromptPay txn, Kross ref, etc.) lives behind the opaque `external_ref` string. OpenRally never interprets the ref — it's stored and echoed for PMS-side audit only. Backward compatible: legacy consumers (BookAndGo, Taykus, pre-1.1 TPS) that omit the block continue to land bookings as payment_status='pending'. OpenAPI bumped 0.1.0 → 0.2.0.
Alternatives considered
Follows the OpenTravel Alliance + Stripe Connect pattern that every mature booking protocol converges on: generic status + generic method_type + opaque consumer reference. Works for TPS today and for any future channel manager / aggregator / marketplace with no protocol changes. Keeps OpenRally payment-agnostic per PRD §2.4 — the block is metadata for reconciliation on the PMS side, not a processing interface.
Extend `emit_booking_event()` to watch `payment_status` and `payment_method` in addition to existing columns. A change on either fires a `booking_modified` event with the full payment block in the payload, which flows through the or_events → webhook_subscriptions → delivery worker pipe already shipped 2026-04-20. Closes the reverse-sync gap reported in scenario B (TPS pay-at-club booking settled in person → admin marks paid → TPS never knew).
Alternatives considered
Reuses 100% of the 2026-04-20 webhook infrastructure. Two-line change to the trigger's modified-watch list; consumers get payment state in the same event shape they're already parsing. Backward compatible — `payment` key in the payload is omitted when the booking has no external_payment_ref, so legacy modified-consumers see identical payloads they see today.
Add a new enum value `'external'` to the bookings_payment_method_check constraint (alongside the existing stripe|credits|pending|other|package). When payment_method='external', two new columns MUST be populated: `external_payment_ref` (opaque consumer string) and `external_payment_method_type` (the OpenRally v1.1 protocol enum: prepaid|pay_at_venue|comp|credit|other). DB-level consistency CHECK enforces this.
Alternatives considered
Separating local concern (payment_method, answering 'what bucket is this for Setpoint admin') from protocol concern (external_payment_method_type, mirroring the OpenRally v1.1 enum 1:1) keeps both surfaces evolvable independently. Admin reports stay filterable by 'external' at the top level without leaking the protocol enum into the admin domain.
No reverse sync for manual admin overrides — changes stay LOCAL to Setpoint. The admin UI (booking-detail-dialog) intercepts the Mark-as-Paid click on external-paid bookings and shows an inline amber warning panel explicitly stating "Changes made here will not sync back to the originating system". Admin must click "Override anyway" to proceed. The local PATCH still fires the booking_modified webhook (DEC-159), but consumers that receive it are advised in the protocol docs to treat local overrides as audit-only unless they explicitly opt into accepting them.
Alternatives considered
Payment source-of-truth should be explicitly defined per consumer, not implicitly inverted by admin clicks. The warning + deliberate override matches the Stripe Dashboard pattern for destructive payment state changes. A future revision can add opt-in reverse sync when consumers formalise their source-of-truth contract.
Every `.insert()`/`.update()`/`.rpc()` in apps/admin/src/app/api/v1/webhooks/stripe/route.ts now checks `.error`. On any unexpected error the handler logs structured context (`event.id`, `event.type`, the operation that failed, redacted identifiers — never PII or full payloads) via `console.error` and returns HTTP 500, so Stripe retries the delivery and the failure surfaces in both the Stripe Dashboard Event deliveries tab and Vercel logs. For checkout.session.completed on type=membership_subscription specifically, the handler now probes for an existing active row on (user_id, facility_id) *before* inserting: if present and its stripe_subscription_id is NULL (orphan from a prior Stripe account or admin manual-assign), it UPDATEs that row in place to adopt the new subscription; if present with a *different* non-null sub_id it returns 500 (don't silently overwrite a concurrent subscription).
Alternatives considered
Returning 200 on a failed mutation is the exact pattern that let BUG-019 hide in prod for three weeks — no Stripe retry, no Vercel error log, no Stripe Dashboard failure indicator. Returning 500 is the Stripe-recommended response when processing fails; Stripe retries with exponential backoff (1s/1m/5m/30m...) which buys us automatic recovery on transient failures and a loud signal on permanent ones. The orphan-adoption logic is explicit (probe + branch on whether sub_id is NULL) rather than reactive (catch unique_violation) because the three outcomes — insert new, adopt orphan, anomaly-fail — have genuinely different semantics and need to be visible in the code, not hidden in an exception handler. The concurrent-sub case (different non-null sub_id) deliberately 500s instead of auto-resolving because it may indicate a double-charge race that requires admin eyes, not silent merge. Full regression coverage in apps/admin/e2e/tc-107-membership-webhook.e2e.ts (5 scenarios: happy path, orphan adoption, retry idempotency, concurrent-sub 500, signature validation).
Single event source (`or_events` table) with two independent delivery paths. A Postgres trigger `emit_booking_event()` writes to `or_events` on booking lifecycle changes. Path A: OpenRally SSE stream `/openrally/v1/stream` (untouched — served by the Rust protocol server for live-dashboard / channel-manager consumers). Path B (new): `webhook_subscriptions` table + `fan_out_or_event` trigger on or_events + `webhook-delivery-worker` edge function that POSTs Stripe-style envelopes (`id`/`type`/`created`/`data`) with 10s/60s/600s retry backoff and dead-letter. TPS consumes via Path B (new inbound edge function `webhook-setpoint-booking-update` in padel-backend).
Alternatives considered
Mobile apps are backgrounded most of the time — SSE doesn't help a closed app, and adding FCM infrastructure is a heavier lift than an inbound webhook on TPS's existing Supabase. TPS already owns a near-identical inbound-webhook pattern (webhook-external-cancellation for BookAndGo/Taykus), so the receiver lands as a new branch of that pattern — no new protocol to maintain on their side. Keeping or_events as the single source of truth means OpenRally's SSE push stays untouched and generic; webhooks are Setpoint-internal plumbing, not a protocol-level addition (deferred to OpenRally v1.1 per protocol/PRD.md §11). HMAC signing deferred to match TPS's existing no-signature convention; webhook_subscriptions.secret column is ready when we enable it.
Lock cost split to 'organizer pays in full' in admin app. The toggle is visible but disabled. Per-player cost splitting (each player pays their share) is deferred to the online/web app booking flow as a future feature.
Alternatives considered
CEO directive: cost splitting is needed for online transactions, not in-club. POS is completely detached from PMS. Admin bookings are walk-ins/phone reservations where staff collects payment at the front desk. Per-player payment splitting adds complexity that doesn't match the admin workflow. The web app will handle per-player payments when cost splitting is built for online bookings.
Add confirmation_code column to bookings table. Format: SP- + 6 alphanumeric chars (30-char alphabet excluding 0/O/1/I/L). Generated server-side at booking creation/confirmation. Stored once, read everywhere. Postgres trigger as safety net.
Alternatives considered
Every app was displaying a different reference for the same booking — admin calendar showed SP-FFFFFFFF, terminal showed #F00001, bookings list showed FFFFFFFF. A guest calling in with their code could not be found. Storing a single code in the DB eliminates all client-side derivation and ensures consistency across admin, terminal, web, and email.
Use the existing booking_type column. Fix match booking creation to set booking_type='match' (was defaulting to 'court'). Backfill existing match bookings via migration. Add type filter param to admin search API and type filter badges to admin UI.
Alternatives considered
The booking_type column already exists and is the correct place for this data. Fixing match bookings to use 'match' instead of incorrectly defaulting to 'court' is architecturally correct from day one. Simple column filtering is more efficient and reusable than join-based detection.
Add payment_method (text, nullable) and player_package_id (uuid, FK to player_packages) columns to the existing lesson_players table.
Alternatives considered
lesson_players already represents the player-lesson relationship. Adding payment columns keeps the data model flat and avoids extra joins. The columns are nullable for backward compatibility with existing data.
Purchase time. Package revenue is recognized when the package is purchased (lump sum). Per-lesson revenue only counts non-package players to avoid double-counting.
Alternatives considered
A 5-session package at ฿6,000 is revenue when purchased. Counting it again when sessions are consumed inflates revenue numbers. Non-package per-lesson revenue gives accurate daily income figures.
Keep it for backward compatibility with private lessons. Private lessons use lesson-level package (one package covers all players). Group lessons use per-player payment on lesson_players.
Alternatives considered
Private lessons with multiple players (DEC-142, e.g., couple's lesson) share one payment. Group lessons are public classes where each player pays individually (DEC-146). Different payment semantics for different class types.
Per-player. Each player in a group lesson pays individually — via their own package session or other payment method. Group lessons are public classes that players join independently.
Alternatives considered
Boss directive: 'Group coaching is paid individually. These are in fact public classes people will join individually.' Current system wrongly deducts 1 package session from the primary player and considers the entire lesson paid — other players are not charged.
Yes — both POST and PATCH endpoints validate player count against class max_pax. Return 400 if exceeded.
Alternatives considered
Belt and suspenders — UI disables add button at capacity, API enforces as hard constraint. Prevents race conditions and API misuse.
No — player name comes from customers JOIN via user_id. For guests without accounts, display email as identifier.
Alternatives considered
Avoids data duplication and staleness. If guest names are needed later, that's a separate feature.
No — min_pax is informational only. Lessons can be created with 0 players. Operators need flexibility to create lessons first and add players later.
Alternatives considered
Operators often create lessons before players are confirmed. min_pax is a business guideline for the class type, not a constraint on individual lessons.
Yes — private lessons can have multiple players (e.g., couple's padel lesson). class_type is about session format (dedicated vs shared), not headcount.
Alternatives considered
Real-world use case: couple's private lesson has 1 coach, dedicated session, but 2 players. Don't conflate session format with capacity.
No — player removal just deletes the lesson_players row. Payment/refund is a separate concern handled through the payments system.
Alternatives considered
Keep it simple for pre-production. Payment logic should not be coupled to player roster management.
No — recurring lessons is a separate feature. Build it in its own PR after multi-court and multi-player are both merged.
Alternatives considered
Recurring lessons requires its own clarify/plan cycle: UI for repeat toggle + week count, generation logic checking per-week availability across N courts, conflict handling (skip vs fail), cancel-one vs cancel-all, edit propagation. Bundling it into multi-court creates scope creep. The generate-lessons.ts backend (for player package subscriptions) already exists but doesn't validate num_courts — that will be fixed when recurring is built properly.
No — skip for now. Admin picks specific courts by name from a list, so mixing is a user choice, not a bug. Only relevant for facilities with mixed sport courts.
Alternatives considered
The admin selects courts explicitly from buttons showing court names. They won't accidentally mix sports. The theoretical edge case (facility with padel + tennis courts) doesn't justify the added complexity. Can be revisited if operators report confusion.
Deferred — we are focusing on admin app only. The admin POST /api/admin/lessons already validates this. Public API will be fixed when the public web app is updated for multi-court.
Alternatives considered
Boss directive: focus on admin app. The public web app doesn't expose multi-court lesson booking yet (players don't choose courts). Fixing the public API without updating the public UI creates no user-facing benefit.
Build court editing in PATCH now — cancel old bookings, create new ones, update lesson_courts.
Alternatives considered
Pre-production boldness: architecture must be correct from day one. No data to migrate, no users to disrupt. Deferring creates known-broken code that the next developer inherits without context. Cancel+recreate is a workaround, and workarounds are tech debt.
Show on both court rows with a visual link (matching color, icon, '1/2' and '2/2' labels).
Alternatives considered
Admins need to see exactly which courts are blocked. Hiding one court means the admin doesn't see the full picture and might try to book over it. This matches how Google Calendar renders multi-room events and how hotel PMS systems (Opera, Mews) render multi-room reservations.
No — keep dots as approximate 'something is available' hints. Per-day detail APIs filter by num_courts when user drills in.
Alternatives considered
At the calendar level, no class is selected yet — there's nothing to filter against. Every booking app (Calendly, Playtomic, Google Calendar) shows 'availability exists on this day' and refines when you drill in. Over-filtering at the range level would hide dates that have availability for other class types.
price_amount = 0. Lesson-type bookings exist only to block the court calendar. The lesson owns the revenue (price_per_pax_amount). Payments table tracks actual money.
Alternatives considered
The lesson is the product, the court is infrastructure. This is the universal pattern in court/studio booking systems (Playtomic, Mindbody, ClubSpark). Duplicating per-pax price onto each court booking inflates revenue when summing bookings. Court cost analysis can be derived from pricing rules at query time if ever needed. Forward-only fix — no migration for existing data (pre-production, no multi-court classes exist yet).
Dynamic — cap at the facility's actual court count.
Alternatives considered
Showing 'max 10' when a facility has 3 courts is confusing and error-prone. The class form already has facility context. Every booking system caps resource selection at what actually exists.
Add 'checked_in' to the bookings.status CHECK constraint via migration.
Alternatives considered
The CHECK constraint is a valid safety net. The value was simply missing — an oversight when check-in was implemented. Adding it to the constraint is the minimal correct fix. Removing the constraint would allow invalid data.
Court Booking and Coach Booking.
Alternatives considered
CEO directive. 'Court Booking' and 'Coach Booking' are clearer for operators and players — they describe what you're booking (a court vs a coach).
Each app enhances its own calendar.tsx independently. No shared UI package.
Alternatives considered
Admin calendar (month picker to jump the grid) and web calendar (Cal.com date picker + slot list) have fundamentally different behavior. Creating a shared package for one component adds build complexity for minimal gain. Both apps already have independent shadcn component libraries.
All bookings happen on the homepage calendar. Academy page is informational only (coaches, classes, packages). Coach profile links to homepage with ?coach= param.
Alternatives considered
CEO direction: one place to book, everywhere else is informational. Eliminates 3 separate booking flows (12 components, ~3,800 lines) and the confusion of having booking in multiple places. Academy page still has value for showcasing coaches and selling packages.
Cal.com / Calendly pattern: month calendar on left (or top on mobile), available slots on right (or below). No booking window limit.
Alternatives considered
Cal.com solves the exact same problem (pick a date, see available time slots, book one). Month calendar is universally understood. Date strips limit to 7-8 days which prevents advance booking. No booking window limit allows users to book as far ahead as they want.
Yes. All booking display locations must LEFT JOIN through lesson_courts → lessons → coaches/classes. Show coach name, class info, and a visual lesson indicator. 6 admin spots + 2 web spots + 4 API routes need updating.
Alternatives considered
Admins and players currently cannot tell if a booking is a lesson or who the coach is from any booking view. The data exists in the DB (lesson_courts junction) but is never fetched. LEFT JOIN preserves backward compatibility — regular bookings show null coach fields.
Coach-first for web — player picks coach, then sees available classes for that coach. Admin stays class-first. If coach has one class, auto-select and show as info. If multiple, show cards with name/type/duration/price. Manual private/group and duration pickers removed — class selection determines these.
Alternatives considered
Players think 'I want Coach X,' not 'I want a Private Lesson template.' Admin thinks in scheduling terms (class-first). Current web flow silently auto-assigns the first class — user has no visibility or choice if coach has multiple classes. No validation that lesson type matches class type.
Yes — classes are mandatory templates for lessons. Every lesson is an instance of a class. The lessons.class_id NOT NULL constraint is correct and intentional.
Alternatives considered
Deviates from original PRD (prd-academy-coaches.ts) which had no class_id on lessons — PRD designed lessons as standalone (coach + court + time + player). Classes were added during implementation to enable reusable pricing templates, package session matching by class_id/class_type (DEC-120), and consistent lesson configuration. This is a good architectural improvement over the PRD design.
Member loyalty view lives at apps/web /account/loyalty only. OpenRally and TPS do not expose loyalty data in v1.4.
Alternatives considered
Web app is the current member-facing product for Pad Thai Padel. TPS integration requires OpenRally Protocol changes outside v1.4 scope. Ship member visibility quickly without blocking on protocol design.
Redemption is staff-only in v1.4 — performed by staff in the CRM admin. Members see rewards but cannot self-redeem. Self-serve deferred to v1.5/v2.0.
Alternatives considered
Front-desk redemption is the current Pad Thai Padel workflow. Staff control prevents misuse during launch and adds trust and auditability before opening self-serve. Reduces v1.4 scope and risk.
One program per facility — enforced by UNIQUE constraint on crm_loyalty_programs(facility_id). No multi-program support in v1.4.
Alternatives considered
Simpler architecture. Single member UX (one balance, one tier). Multi-program adds join complexity with unclear member benefit. Can be re-evaluated post-v1.4 if a facility requests it.
Tier is calculated from lifetime_points (total ever earned) — never decrements when points are spent. Redeeming rewards decrements points_balance only; tier is permanent once reached.
Alternatives considered
Industry standard (Starbucks, airline miles). Members who've earned Gold status shouldn't lose it because they redeemed a reward. Lifetime points are a truer signal of loyalty.
Unified replacement: one CRM loyalty program per facility replaces both pos_loyalty_programs (POS-only loyalty) and credit_tier_configs (membership discount tiers). All earning, tier calculation, and discount application goes through crm_loyalty_accounts.
Alternatives considered
Two disconnected loyalty systems creates fragmented member experience and double maintenance burden. A single source of truth simplifies tier discounts, member views, and future integrations.
No transformation needed. Existing USD values are already in cents (smallest subunit). The rename is column-name-only — data stays identical.
Alternatives considered
All Sunset Padel values (3000–9900 range) are already in USD cents. Column RENAME in PostgreSQL preserves data, CHECK constraints, and indexes. Zero risk.
Predefined roles: owner, manager, admin, reception, coach, sales, other. 'Other' as escape hatch.
Alternatives considered
Predefined roles enable filtering and permissions later. 'Other' prevents blocking on edge cases. Deferred to PR 2 (settings overhaul).
TypeScript constants in packages/common (currency.ts). Shared across admin, web, and POS apps.
Alternatives considered
Constants are fast, typed, and don't require DB queries. The list changes rarely (ISO standards). packages/common is already the shared layer.
Yes — include district and neighborhood columns. Building for Asia first (Bangkok has districts/neighborhoods, not just city/state).
Alternatives considered
CEO directive is Pad Thai Padel (Bangkok). Thai addresses use district (khet) and neighborhood (khwaeng). Deferred to PR 2.
Rename existing columns (address → address_line1, state → state_province, zip → postal_code, country → country_code) + add new (address_line2, district, neighborhood, lat/lng).
Alternatives considered
Pre-production — clean break is safe. Rename makes column names internationally accurate. Deferred to PR 2.
Two separate PRs. PR 1: multi-currency (column rename + currency utilities + UI). PR 2: settings overhaul (address + contacts).
Alternatives considered
Smaller PRs are easier to review, test, and revert. PR 1 unblocks THB immediately. PR 2 can follow independently.
Rename _cents → _amount. Store in the currency's smallest subunit (cents for USD, satang for THB). THB is a two-decimal currency (NOT zero-decimal in Stripe). ฿1,400 stored as 140,000 satang. Display is always ÷ 100 for two-decimal currencies. Conversion service needed only for truly zero-decimal Stripe currencies (JPY, KRW) if ever supported.
Alternatives considered
CEO directive: 'cents is the way to go, ALWAYS. It's a standard de facto in fintech.' Storing in smallest subunit keeps all internal math uniform. The column name _amount was chosen because 'let's simply call it amount and store cents' (CEO). THB confirmed decimalized — has satang, not on Stripe's zero-decimal list.
All three products (Setpoint, OpenRally, TPS) and the club (Pad Thai Padel) are ours — built and owned by SuperStack. Setpoint PMS handles internal club operations. The Padel Society (TPS) is our consumer-facing mobile app. OpenRally Protocol is the middleware that connects them. TPS never connects to Setpoint directly — OpenRally is always the middleman.
Alternatives considered
CEO confirmed: 'TPS doesn't connect to setpoint directly.' OpenRally is a protocol — a clean abstraction boundary between internal ops (Setpoint) and consumer-facing features (TPS). This enables third-party apps to also consume the protocol, not just TPS. Pad Thai Padel is our own club — the first real-world facility running on our full stack.
All post-login, post-create, and facility-switch redirects now land on /dashboard instead of /calendar
Alternatives considered
Dashboard is now the facility home page (DEC-105). Landing on the overview first gives operators context before diving into the calendar. Consistent with Dashboard being the first sidebar item.
Rename Reports → Dashboard, move to first nav position, add facility root redirect, change icon to LayoutDashboard
Alternatives considered
CEO directive: 'we are missing a dashboard, we can change /reports to dashboard and work in proper reporting later.' Dashboard sets the tone as the facility's home page and should be the first thing operators see.
Remove all seed data from migrations (pure schema only), delete seed.sql entirely, and populate exclusively through a 73-test Playwright suite (demo-populate) that creates everything via real user flows — admin UI + web OTP + Stripe payments.
Alternatives considered
Real user flow population ensures data integrity — every record goes through the same validation, triggers, and business logic that real users experience. Seed scripts bypass these and can create inconsistent data. The demo-populate suite also serves as a comprehensive smoke test of all features.
No. Migrations are strictly schema — CREATE TABLE, ALTER TABLE, CREATE POLICY, CREATE FUNCTION. Zero INSERT statements for demo data. Seed data was removed from 8 migration files (PR #154).
Alternatives considered
Mixing seed data with schema migrations means production gets demo data on every db push. Migrations should be environment-agnostic — same schema for local, staging, and production. Data population is a separate concern handled by the demo-populate suite.
Permanently banned. Production data is populated through real user flows and must never be wiped. Schema changes go through additive migrations via supabase db push only.
Alternatives considered
Production reset destroys all user-flow-created data requiring ~10 min re-populate. One-time reset was done to clean up legacy seed data. Going forward, all changes are additive migrations.
Fully aligned. All 65 existing tests pass without changes. v0.16 match-linked bookings are compatible by design — materializer treats them as regular bookings. No code changes required.
Alternatives considered
The materializer reads bookings by (court_id, start_time, end_time, status) regardless of how they were created. Match bookings (via admin app) and OpenRally bookings use the same bookings table. The v0.16 restructure added booking_id FK on matches table (matches→bookings direction), which OpenRally doesn't reference. 90 total tests now verify alignment.
Comprehensive real-world scenario tests (25 new tests) covering match-linked bookings, multi-facility isolation, hold lifecycle, booking lifecycle edge cases, availability edge cases, SSE events, and discovery/auth. Total: 90 tests.
Alternatives considered
Integration tests against the real DB catch actual runtime behavior — schema comparisons only catch structural issues. The 25 new tests cover the specific v0.14–v0.16 additions (matches, multi-facility, booking restructure) that could have caused regressions.
Three-tier auth: (1) Public read endpoints (facility, courts, availability, matches, coaches, plans, etc.) require NO auth — open to everyone including guests. (2) User mutation endpoints (booking, payment, join, enroll, profile) require Supabase JWT. (3) API key auth kept as ALTERNATIVE for third-party consumers (OpenRally). Web app removes NEXT_PUBLIC_API_KEY entirely. ~19 GET routes become auth-free, ~10 booking mutation routes move from API key to JWT.
Alternatives considered
Web app is first-party, not a third-party API consumer. Public facility data should be browsable by anyone (guest booking system). API keys are for machine-to-machine third-party access (OpenRally, future integrations). JWT is the standard auth for user-scoped operations. This aligns with DEC-097 (OpenRally is external, web app is internal).
No. Setpoint Web keeps its own Setpoint APIs. OpenRally is exclusively for external consumers — third-party apps, aggregators, channel managers, and Google Reserve.
Alternatives considered
Setpoint and OpenRally are separate products. A different application will consume the OpenRally endpoints. Setpoint Web has product-specific needs (memberships, credits, programs, coaching) that go beyond protocol scope. Keeping them separate avoids coupling Setpoint's product roadmap to the protocol's evolution.
Verify redirect to checkout.stripe.com (T10). Full round-trip webhook test in payments-membership.e2e.ts skips gracefully if STRIPE_WEBHOOK_SECRET not configured. Stripe CLI is a manual prerequisite, not automated.
Alternatives considered
Stripe CLI requires manual login and a running process — automating it in globalSetup is fragile. The redirect test validates the subscribe flow without webhook dependency. Full webhook test available when CLI is running.
No — Stripe CLI requires interactive login and a persistent process. Keep it as a manual prerequisite documented in the test file. Membership webhook tests skip gracefully if not running.
Alternatives considered
Manual prerequisite is simpler and avoids CI complexity. Most payment tests (PaymentElement flows) don't need webhooks at all — only membership subscription does.
Browser-first E2E tests using Playwright frameLocator() to interact with Stripe PaymentElement iframes. 10 tests in payments.e2e.ts + 2 in payments-membership.e2e.ts. Covers: court booking, credit top-up, program enrollment (paid + free), match join (paid + free), declined cards, membership redirect.
Alternatives considered
Real browser tests catch iframe rendering issues, payment flow UX bugs (like BUG-009), and integration problems that API-level tests miss. Using Stripe test card numbers (4242...) in test mode is officially supported. BUG-009 (match join never charges card) was discovered during this work and fixed.
Added admin server (port 3000) to web playwright.config.ts webServer array, matching the pattern already used in admin config.
Alternatives considered
Web app SSR fetches facility data from admin API routes. Without the admin server, all facility pages return 404. Auto-starting both servers is the correct approach for zero-manual-setup.
Root cause: PL/pgSQL RECORD IS NOT NULL returns false when any field is NULL. user_memberships has nullable stripe_* fields. Fixed by using FOUND special variable instead of IS NOT NULL. Applied same fix to the v_rule check.
Alternatives considered
FOUND is the idiomatic PL/pgSQL way to check if SELECT INTO returned a row. IS NOT NULL on RECORDs is a known footgun in PostgreSQL — it checks ALL fields, not just whether a row was found.
Replaced auth.admin.createUser() with POST /auth/v1/signup endpoint. Falls back to sign-in + profile lookup for existing users.
Alternatives considered
CLAUDE.md already documents this: 'Supabase local auth — use signup, not admin API. The admin endpoint returns 403 locally.' The signup endpoint works with both JWT formats and is the documented best practice.
7 E2E tests in apps/web/e2e/match-emails.e2e.ts using Mailosaur for real email verification. Tests cover: match creation with invite → email received → content validated → invite page loads → result submission → result email received → result content validated.
Alternatives considered
Mailosaur provides real email delivery verification through the full chain: Admin API → Edge Function → Resend → inbox. Tests gracefully skip if MAILOSAUR_API_KEY not set, so they don't block CI or local dev without Mailosaur. Total: 270 tests (191 admin + 75 web + 4 demos).
Consolidate 51 short clips (9 files) into 4 journey-based demos (4 files): Admin Operations, Academy & Lessons, Matches & Ratings, Public Web User Journey. Each demo is a single continuous 2-5 min recording covering a complete user journey.
Alternatives considered
Boss feedback: demos were too short, many just showed page loads with no meaningful interaction. Journey-based demos tell a complete story (signup → configure → use) and are more useful for team visibility. Reduces maintenance burden (4 files vs 9). Shared helpers in demo-helpers.ts eliminate duplication.
E2E only — no unit or integration tests. Keep admin API-based E2E tests (Playwright request client). Web app tests use browser UI. No additional test layers.
Alternatives considered
Lean testing: E2E covers the full stack (browser/API → DB) and catches real regressions. Admin API tests are fast (no browser), stable (no selectors), and test internal APIs that have no web UI consumer. Adding unit/integration layers would increase maintenance without proportional value at this stage. 263 E2E tests run in ~4 minutes total.
Separate demo videos into apps/admin/e2e/demos/ (own Playwright project, excluded from test runs). Replace API-only tests with web app UI E2E tests that exercise the same endpoints through real browser flows. 80/20 approach: happy paths + key errors, no exhaustive parameter testing.
Alternatives considered
Boss feedback: demos were short and irrelevant, not simulating real user flows. API-only tests duplicated coverage that web UI tests provide more realistically. Separating demos prevents them from polluting test counts and run times. Web UI tests exercise the full stack (browser → API → DB) as real users would.
Public API endpoints are tested through web app UI E2E tests only. No separate API-only test files. Admin app only tests admin dashboard UI + admin API endpoints.
Alternatives considered
The web app IS the consumer of the public API. Testing through the UI exercises the same endpoints while also validating rendering, state management, and user flows. Removes 101 redundant API-only tests. Web UI tests are more realistic since they simulate actual user behavior.
Consolidate all per-app env files into two root files: .env.local (local dev) and .env.production (production testing). Each Next.js app loads from root via dotenv in next.config.ts. Templates: env.local.example and env.production.example.
Alternatives considered
Per-app env files were fragmented and easy to desync. Single root files ensure consistency, simplify onboarding, and prevent shell env var conflicts (user had production creds in ~/.zshrc overriding local values). Production env file is never read by agents — only loaded via dotenv.
Use Mailosaur test inboxes as recipients. Edge Functions send real emails via Resend → Mailosaur intercepts → tests verify content via Mailosaur API. Requires Resend domain verification (omnigpt.co).
Alternatives considered
Mailosaur provides programmatic email verification — the only way to make email tests fully automated and self-assessing. Mocking defeats the purpose of production E2E testing. Manual checking doesn't scale.
JSON state file (e2e/production/.state/run-state.json) — each test writes data (facility slug, match IDs, tokens) for later tests to read. File is gitignored.
Alternatives considered
JSON file is simple, debuggable (can inspect between runs), and works across serial test files without Playwright fixture complexity. DB queries would couple tests to schema details.
Drop court_id, sport_type, start_time, end_time, duration_minutes, cost_split, total_price_amount, cost_per_player_amount, currency — all derived from booking join.
Alternatives considered
Eliminates data duplication. All data lives on booking. API responses flatten via JOIN for backward compatibility.
Keep client-side Elements confirm (no `confirm: true`). Use `automatic_payment_methods: { enabled: true }`. Wrap in try/catch.
Alternatives considered
Server-side confirm requires a payment_method which isn't available at PI creation — the join flow uses client-side Stripe Elements to collect payment. Added automatic_payment_methods and error handling.
Yes — admin and organizer can toggle visibility after creation.
Alternatives considered
Flexibility. An organizer might start public then switch to private once enough friends join.
Keep max_players the same — court-driven, not visibility-driven.
Alternatives considered
A private match on a padel court still has 4 spots. Visibility controls who sees it, not capacity.
Backfill: create booking rows for orphan matches, then set NOT NULL constraint.
Alternatives considered
Clean data model with no nulls. Backfill preserves existing data. NOT NULL enforces the invariant going forward.
~8-10 visible fields. Sport derived from court selection. Level range optional. Remove cost inputs (auto-calculated from booking).
Alternatives considered
Boss directive. Court selection determines sport. Pricing belongs on booking. Less fields = faster match creation.
Bidirectional cascade: cancel match → cancel booking, cancel booking → cancel match.
Alternatives considered
A match without a booking is meaningless (no court). A booking for a cancelled match should free up the court.
Pricing model (total, cost_split, per_player) on booking. Per-player Stripe payment tracking stays on match_players.
Alternatives considered
Booking owns the court reservation and its cost. Match players reference the booking's pricing. No duplication.
`public` / `private` — two states only.
Alternatives considered
Simple binary. Public = listed on facility page. Private = invite-only, not listed. Covers all use cases without complexity.
None — v0.15.2/v0.15.3 already shipped.
All prerequisite versions are merged. Clean start.
Keep both: booking_players (court access) + match_players (game data).
Alternatives considered
Different concerns. A booking can have players who aren't in the match (e.g., spectators with court access). Match players track game-specific data (level, team, score).
Rename to `matches` — drop the `open_` prefix.
Alternatives considered
Matches can be private now, so 'open' is misleading. `matches` is cleaner and matches the domain language.
Match auto-creates booking (single action). Booking = foundation (court, time, price). Match = optional layer on top.
Alternatives considered
Boss directives DEC-041/042/043: booking is the foundation, match is optional. Creating a match always needs a court reservation, so auto-creating the booking reduces friction.
Immediately available. No draft/live concept.
Alternatives considered
Adding draft/live is scope creep. If admin hasn't set up courts, public page shows no availability — self-policing. OpenRally auto-discovery is a feature, not a bug.
No. Automatic. OpenRally server reads from the same DB — any facility in the facilities table is discoverable.
Alternatives considered
OpenRally already queries all facilities. No code needed.
They land on the facility selector empty state with 'Create your first facility' CTA. No partial facility state — creation is atomic.
Alternatives considered
Atomic creation avoids orphaned/partial facilities. Empty state with CTA is the resume mechanism. Same component serves both new users and existing users adding facilities.
Auto-suggest alternative by appending -2, -3, etc. User can edit. Real-time validation.
Alternatives considered
Auto-suggestion is frictionless. Real-time validation prevents form submission failures.
Show explicit message: 'Already have an account? Log in' with link to /auth/login.
Alternatives considered
Be explicit, not clever. Users should know what happened and have a clear next step. Standard SaaS pattern.
No. Start clean. Excellent empty states with CTAs guide the admin through setup.
Alternatives considered
Fake data confuses admins — they don't know what's real. Empty states with clear CTAs are the onboarding. Post-signup experience acts as a natural checklist.
Timezone: auto-detect from browser. Currency: USD. Operating hours: 8am–10pm all days. All editable in settings after.
Alternatives considered
Smart defaults reduce onboarding friction. 8am–10pm covers most clubs. Browser timezone detection is reliable. All editable later.
Yes. No artificial limits. Multi-facility infrastructure already supports it.
Alternatives considered
Infrastructure already supports multi-facility (v0.15.0). Artificial limits create friction with no benefit pre-GA.
No. Stripe setup happens in settings after onboarding. Admins can explore the product first.
Alternatives considered
Stripe setup is complex and would kill the onboarding flow. Let admins explore first, connect Stripe when ready. Standard SaaS pattern (Shopify, Square, Toast).
Owner.
Alternatives considered
Creator is the owner. Non-negotiable.
Anyone who signs up. No approval gate. Self-serve SaaS = zero friction.
Alternatives considered
Friction kills conversion. Solve abuse when it becomes a real problem, not hypothetically.
Auto-generated from facility name via slugify. Shown to user, editable. Real-time uniqueness validation.
Alternatives considered
Auto-generation reduces friction. Editable gives control. Real-time validation prevents frustration.
Minimal: facility name, sport type(s), timezone. Everything else (courts, pricing, hours) from settings after.
Alternatives considered
Long wizards have high abandonment. Collect minimum to make product functional. Empty states with CTAs guide post-onboarding setup.
Keep email + password. Already built, already working.
Alternatives considered
Password is already implemented. Admins are power users who log in frequently — password is faster. Magic link can be added as alternative later. Changing auth method is separate scope.
'Don't have an account? Sign up' link on the login page → /auth/signup route.
Alternatives considered
Standard SaaS pattern. Login page is the natural entry point for admin apps. Separate route keeps concerns clean.
Click to open a Popover. Not hover.
Alternatives considered
Click is more intentional and mobile-friendly. Hover popovers cause accidental triggers on dense player lists. Popover is lightweight and dismissible.
Last 5 completed matches shown as colored dots — green for win, red for loss. Show '—' if no match history.
Alternatives considered
Dots are compact, instantly scannable, and a well-known pattern (FIFA, sports apps). 5 matches balances recency with data availability.
Per-app (apps/admin/src/components/ui/ and apps/web/src/components/ui/). Small components (~20 lines each), minimal duplication.
Alternatives considered
packages/common is utility-only (no React). Creating a shared UI package adds build complexity for two tiny components. Per-app is pragmatic.
Dedicated POST /api/v1/me/avatar endpoint with JWT auth. Separate from facility-assets upload.
Alternatives considered
Avatar upload is user-scoped (JWT auth), not facility-scoped (admin auth). Dedicated endpoint ensures path is {user_id}/avatar.{ext} and updates customers.avatar_url atomically.
Bucket 'avatars', public read, 2MB limit, image/jpeg|png|webp only. Path: {user_id}/avatar.{ext} with upsert.
Alternatives considered
Separate bucket for user content vs facility content. 2MB is sufficient for avatars (smaller than 5MB facility assets). Public read avoids signed URL overhead. Upsert keeps one avatar per user.
Yes — add user_id to the player response mapping in GET /api/v1/matches/[id]. Already selected from DB, just stripped in the response.
Alternatives considered
The web app needs to detect if the logged-in user is a match participant to show the correct result UI (submit vs confirm vs read-only). Adding user_id to the existing response is the simplest solution with no extra round trips.
Integrated into the existing match detail page — no new routes. Result section renders contextually based on auth state, participant status, and result status.
Alternatives considered
The match detail page is the natural home for results. Players arrive via email links pointing to the match. Adding a new route creates navigation complexity. The conditional rendering (6 states) fits cleanly into the existing page structure.
ALTER column to NUMERIC(6,2) — max 9999.99. Elo range 800-2400 fits comfortably.
Alternatives considered
NUMERIC(6,2) is the minimal change that supports standard Elo values (starting at 1500, typical range 800-2400). No data cleanup needed since the INSERT always failed — no Elo rows exist.
v0.15.3 = Avatar + Player Card (ASAP, self-contained). v0.16 = Booking/Match Restructure + Private Matches (architectural, needs planning). v0.15.2 (player result UI) stays as-is.
Alternatives considered
Avatar/player card is independent, boss said ASAP, no schema conflicts. Booking/match restructure is architectural and needs careful planning — separate version avoids rushing it.
Yes — reusable player card showing pic, level, Elo, recent form. Appears on click anywhere players are shown (matches, bookings, customers).
Alternatives considered
Boss confirmed: 'Build a player card — yes.' A reusable card is more versatile than a full page — works across all views.
Yes — ASAP. Add upload UI, store in Supabase Storage, display across all views. The avatar_url field already exists in customers table since v0.1.
Alternatives considered
Boss directive: 'Add avatar ASAP.' Players should have pic and level always visible everywhere.
Booking = foundation (court, time, price, payment, players). Match = optional layer on top of booking (visibility, level range, match type, Elo). A lot of data currently in 'match' belongs in 'booking' — especially payment.
Alternatives considered
Boss feedback: 'A lot of the data in match belongs to booking — such as payment, etc.' This inverts the current model where matches independently carry booking-like fields.
Add visibility (public/private) to matches. When booking, allow creating a match and setting visibility. Private matches are only accessible via invite link — not shown in public listings.
Alternatives considered
Boss directive: 'We need to add visibility (public/private). When we book, we need to allow creating a match and then set visibility.' Covers the use case of friends booking a court and splitting cost without making it open.
Yes — strip match form to match-specific fields (sport, level range, player count, description, visibility). Court/time/price come from the linked booking, not duplicated in the match form.
Alternatives considered
Boss confirmed: 'Yes, that's the idea.' The current form duplicates booking data (court, time, price) which creates confusion and data inconsistency.
Fire-and-forget — email never blocks/fails the main API response. Resend handles retries.
Alternatives considered
Pre-production product. Same pattern as Elo error handling (log, don't block). No email queue/retry table needed yet.
Simple inline HTML in Edge Function — no template engine or external dependency.
Alternatives considered
Minimal complexity for 5 event types with identical layout. Can upgrade to React Email later if email portfolio grows.
Yes — CTA button linking to match detail page on web app ({WEB_APP_URL}/{facility_slug}/matches/{match_id}).
Alternatives considered
Standard email UX practice. Deep-links users to take action (confirm/dispute result).
All 5: result submitted, confirmed (manual), confirmed (auto-24h), disputed, admin resolved.
Alternatives considered
All infrastructure already in place (3 placeholders + admin resolve endpoint). Covers the full result lifecycle.
No — boss prefers unique shareable links over email for match invitations.
Alternatives considered
Boss directive in v0.14.0 AC review: 'Send a unique link is better' when asked about email invitations vs link sharing.
Supabase Edge Function — single function handles all event types, called from API routes + pg_cron via pg_net.
Alternatives considered
Supabase-native first (CLAUDE.md). Solves the auto-confirm case naturally — pg_net can reach Edge Functions. Single email logic in one place.
Resend — simple API, Deno-compatible via fetch, generous free tier (100 emails/day).
Alternatives considered
Boss communicated Resend preference in v0.14.1 feedback thread. Simple HTTP API works in Deno without npm dependencies.
No immediate refactor needed — document as future technical cleanup. The current approach works correctly but is inefficient (loads all bookings into memory to group by email). When touching this area, refactor to query customers + join bookings for aggregates instead.
Alternatives considered
Current code produces correct results. Not worth a standalone PR, but should be cleaned up opportunistically. Documenting ensures it's not forgotten.
No change needed — the current OTP verification step (after payment) already creates a profile via Supabase Auth (signInWithOtp with shouldCreateUser: true). Every completed booking results in a registered user. There are no truly anonymous completed bookings.
Alternatives considered
Boss confirmed: 'they just log in with OTP, so it's irrelevant later.' The existing flow already handles registration frictionlessly through the mandatory OTP step.
Resolve slug→ID and email→ID server-side in the POST handler, matching the GET handler pattern. Also validate court belongs to facility and compute duration_minutes from start/end times.
Alternatives considered
Server-side resolution is safer (no UUID exposure in client), consistent with GET handler pattern, and allows proper validation. Court ownership check prevents cross-facility bugs. Duration computation fixes non-nullable DB column.
Team Elo = average of members' individual Elo
Alternatives considered
Standard approach for team-based Elo in racquet sports. Simple, well-understood.
K=32, initial 1500, per-facility scope
Alternatives considered
Standard Elo params (chess default). Per-facility keeps ratings contextual. K=32 balances responsiveness with stability.
Only v0.14.1 — v0.14.2 deferred entirely
Alternatives considered
Ship results + Elo first, external integrations later. Reduces scope and risk.
Email = log only (placeholder), real provider wired up separately
Alternatives considered
Email provider is a separate concern. Placeholder logging keeps the feature complete without blocking on email infra.
Dispute replaces score + resets 24h window; 3 disputes escalates to admin_review
Alternatives considered
Balanced: gives players agency to correct scores, prevents infinite loops, admin as final arbiter.
pg_cron job running every 5 minutes
Alternatives considered
Supabase-native, runs in-database, no external deps. 5min granularity is acceptable for 24h windows.
Submitter picks teams (assigns players to team A/B)
Alternatives considered
Flexible — matches real-life team formation on court. Players often shuffle teams between sets.
JSONB array: [{team_a: 6, team_b: 4}, ...] (best of 3)
Alternatives considered
Standard format, flexible for variable set counts, easy to query and display.
Yes — ALTER to (user_id, facility_id, sport_type, level_type)
Alternatives considered
Multiple ratings (self_assessed + elo) must coexist per user/sport. Reusing player_ratings keeps queries simple.
Padel only — drop tennis and pickleball
Alternatives considered
Boss directive. Focus on padel-first, simplifies scoring format and Elo calibration.
Defer — code examples with live server URL are sufficient for v1.
Alternatives considered
Adds significant complexity. The live server at openrally-server.fly.dev can be hit via curl/fetch from code examples. Stripe didn't ship with a playground either.
Defer — focus on content and navigation first. Search comes in v2.
Alternatives considered
16 endpoints fit comfortably in a sidebar. Search adds complexity without proportional value at this scale.
Remove it. The new Next.js app replaces it. openapi.yaml and schemas/ stay in protocol/spec/.
Alternatives considered
The new app is the canonical API reference. Keeping the old page creates confusion about which is authoritative.
Introduction, Authentication, Protocol URIs, Pagination, Idempotency, Errors, Rate Limiting, SSE Event Stream — 8 guides total.
Alternatives considered
These 8 cover every concept a consumer needs. Maps to the info.description sections in the OpenAPI spec.
curl + JavaScript (fetch) + Python (requests) — same three as apps/apidocs.
Alternatives considered
Three languages covers 90%+ of consumers. Consistent with existing apidocs site. More languages can be added later.
Yes — key schemas (Facility, Resource, Slot, Hold, Booking, Sport, Error, PaginationMeta) get dedicated object reference pages.
Alternatives considered
Stripe pattern. Objects are first-class documentation citizens. Consumers need to understand the data model independently from endpoints.
Yes — update the Vercel project root. Project renamed from openrally-setpoint to openrally.
Alternatives considered
The app lives in apps/ like all other Next.js apps. Vercel root must match.
Parse openapi.yaml at build time using js-yaml. Single source of truth — docs update when spec updates.
Alternatives considered
Eliminates double-maintenance. The spec is already comprehensive (1,130 lines). Build-time parsing is fast and reliable.
Copy apps/apidocs/ and adapt. Same shell, different data layer.
Alternatives considered
apps/apidocs/ already has the right layout (sidebar + doc + code), same tech stack (Next.js 16, Tailwind v4), same design tokens, and proven component patterns. Copy + adapt is 3x faster and ensures design consistency.
apps/openrally-docs/ — consistent with apps/admin, apps/web, apps/docs, apps/apidocs.
Alternatives considered
All Next.js apps live in apps/. Spec artifacts (openapi.yaml, schemas/) stay in protocol/spec/ as non-website assets. Clean separation.
camelCase matching Rust handler names: getMetadata, listFacilities, getFacility, listResources, getResource, queryAvailability, listSports, createHold, getHold, releaseHold, createBooking, listBookings, getBooking, modifyBooking, cancelBooking, streamEvents
Alternatives considered
camelCase is the convention used by Stripe, GitHub, Twilio. Maps directly to future SDK method names.
Yes — include reference implementation URL: https://openrally-server.fly.dev
Alternatives considered
Makes the spec immediately testable. Stripe, Twilio, etc. all include live URLs. Implementers can ignore it.
@redocly/cli — npm install, validate with npx @redocly/cli lint openapi.yaml
Alternatives considered
Best OpenAPI 3.1 support, widely adopted, excellent error messages.
Yes — security: [] override on that endpoint. All others require X-API-Key.
Alternatives considered
The code has no Auth extractor on the metadata handler. Unauthenticated metadata enables service discovery.
X-API-Key only. Drop X-OpenRally-Key from the protocol spec entirely.
Alternatives considered
X-API-Key is the industry standard (AWS API Gateway, countless REST APIs). X-OpenRally-Key is vanity branding with zero benefit. The key format (ork_live_...) already identifies it as OpenRally.
No — document only actual request headers the server expects. The Rust server doesn't set custom response headers.
Alternatives considered
Spec must reflect reality. When the server adds these headers, the spec gets updated.
Actual implementation only. No aspirational query params (start_time, end_time, duration on availability) that the code doesn't implement.
Alternatives considered
Task explicitly says 'derive from actual Rust handler code, do NOT guess or invent'. Spec documents reality.
Exclude. It's outside /openrally/v1/ prefix — infrastructure, not protocol.
Alternatives considered
Not one of the 16 protocol endpoints. No implementer needs to replicate it.
Yes — Apache-2.0. First-class OpenAPI info.license field.
Alternatives considered
PRD explicitly states Apache 2.0. Standard for open protocols.
0.1.0 — matching what the server's metadata endpoint returns.
Alternatives considered
The metadata endpoint returns version 0.1.0. Protocol hasn't reached 1.0. Spec version must match server reality.
Single-file spec with all schemas in components/schemas. Standalone JSON Schema files in schemas/ as derived copies for implementer convenience.
Alternatives considered
Single-file is portable and self-contained. Standalone files enable validation by implementers. This is the Stripe/GitHub/Twilio pattern.
protocol/spec/ with openapi.yaml, schemas/ directory, README.md, and LICENSE file.
Alternatives considered
Clean separation: spec file + standalone schemas + docs. Standard layout for open API specs.
YAML as the authoritative source.
Alternatives considered
Industry standard for hand-authored OpenAPI specs. More readable, supports comments. JSON can be generated if needed.
DB function (consume_package_session) with SELECT FOR UPDATE. Atomic, prevents races.
Alternatives considered
PL/pgSQL runs inside the DB transaction boundary — atomic by nature. Edge Functions run outside and cannot guarantee atomicity. Application code requires explicit transaction management. The hold_lesson_slot pattern already proves this approach works.
Full refund: sessions_remaining incremented, sessions_used decremented. Only exhausted→active transition allowed. Never reactivates cancelled or expired packages.
Alternatives considered
The player paid for those sessions. Simple full refund is fair and predictable. The status guard (only exhausted→active) prevents accidental reactivation of intentionally cancelled packages.
Hourly pg_cron job (expire_player_packages) + API-level computed status. Cron is the safety net; API gives instant correctness.
Alternatives considered
Cron follows the proven expire_memberships() pattern. API computation eliminates the 0-59 minute stale window. VIEW was considered but adds RLS complexity (SECURITY INVOKER, PostgREST join limitations) and would be the first VIEW in the codebase.
Yes — one or the other, never both. Package path skips Stripe entirely. No hybrid payments.
Alternatives considered
Hybrid payments add complexity with no clear user need. A session bank covers the full lesson cost. If the player wants to pay by card, they can. Simplicity wins.
Full reset to current package_items.session_count on invoice.paid. Admin changes to package definitions propagate to all active subscriptions.
Alternatives considered
Using live counts means admin can adjust the offering and all subscribers benefit. Snapshotting adds a column and complexity. Accumulation enables session hoarding, which defeats the purpose of a recurring billing period.
Match by class_id exact first, then class_type fallback. Within each tier, drain the smallest bucket first (ORDER BY sessions_remaining ASC).
Alternatives considered
Two-tier matching gives package items flexibility — can reference a specific class OR just a type. Draining smallest bucket first keeps the player's options open longest.
No — cascade only when explicitly requested via cascade:true flag. Default PATCH is a simple status update.
Alternatives considered
Implicit cascading is dangerous — an admin setting status to cancelled might not expect future lessons to be cancelled and Stripe subscription to be terminated. Explicit flag prevents accidental side effects while keeping the API simple.
No — single payment by organizer. One payment_method on the booking. booking_players tracks court access only (DEC-072). Per-player payment deferred to future split-payment feature.
Alternatives considered
DEC-072 established that booking_players is for court access, not payment. DEC-075 puts pricing on the booking. Per-player payment for lessons (DEC-146) exists because group lessons are public classes where players join independently. Court bookings are different — one person books, others just show up.
Populate now. Auto-calculate cost_per_player_amount = Math.round(price_amount / player_count) on every player add/remove. Set cost_split='split' when count > 1, null when count <= 1.
Alternatives considered
The columns already exist and matches already use them. Populating them now completes the infrastructure and gives the split payment feature ready-to-use data. The e2e tests verify the calculations are correct.
Add the column now (nullable timestamp). No logic built around it — just the column. Terminal task will use it for per-player check-in and per-player POS ticket creation.
Alternatives considered
DEC-137 rationale: 'Architecture must be correct from day one. Deferring creates known-broken code.' Adding a nullable column costs nothing but saves a migration cycle later.
Show the single email field when no players have been added (backward compat). Hide it and show the multi-player input once a player is added. First player's email syncs to guest_email for pricing/member lookup.
Alternatives considered
Single-player bookings are still the most common case. Showing the familiar email field by default avoids confusing staff. The multi-player input appears naturally when needed. First player = organizer = guest_email for backward compat.
Separate endpoints: POST/DELETE /api/admin/bookings/[id]/players. Follow the lesson_players pattern exactly (POST /api/admin/lessons/[id]/players).
Alternatives considered
Separate endpoints give atomic operations (add one, remove one) with automatic cost recalculation. Follows the established lesson_players pattern. PUT-replace risks race conditions when multiple admins edit simultaneously.
Replace the JS-side pipeline with a single Postgres SQL function `crm_search_customers(p_facility_id, p_q, p_filter, p_sort, p_limit, p_offset)`. Entry point is `facility_customers` JOIN `customers` (structurally facility-scoped). Search, filter, sort, pagination, and aggregation all run server-side in one query. The route becomes a thin RPC caller.
Alternatives considered
Built-in-first (SQL functions are a Postgres primitive, matches existing atomic-function precedent in this codebase). Architecture-first (the function makes facility scoping structural, not a flag a developer must remember). Removes the silent-truncation class of bug entirely: a future edit cannot accidentally re-introduce the 1000-row cap because the function bakes in LIMIT/OFFSET. Behavior parity preserved with the previous route (same last_visit/total_spend/role definitions). Orphan listing route /api/admin/customers and component customers-list.tsx deleted in the same PR — no nav link and CustomersList only imported by the orphan page.
Added a new additive join table `pos_catalogue_locations` (catalogue_id, location_id, facility_id) — absence of rows for a catalogue means 'visible at all locations' (matches prior default, no backfill needed). Reframed `pos_catalogues.channel_type` in the UI as a single 'Display mode' toggle (Staff terminal / Self-service kiosk) instead of a 'sales channels' multi-select — that's what the column actually is (a single flag consumed by apps/pos and apps/terminal to distinguish kiosk vs staff-operated rendering), not a list of assignable online/marketplace channels. Built a real 'Catalogue Settings' entry point on the catalogue detail page (`menus/[id]`) exposing both, wired to real mutations. Device-level deployment (`pos_catalogue_devices`, managed at Settings → Devices) is left as-is and referenced from the new settings panel rather than being rebuilt.
Alternatives considered
The prior UI actively misled owners: it showed 'All locations' and '0 of 1 channels' as if configured, saved neither, and pointed at a settings page that never existed. No-debt bias — build the real primitive (location join table) rather than patch the wizard's copy alone. Empty-rows-means-all avoids a backfill migration and keeps existing catalogues working unchanged. Reused the existing pos_catalogue_devices mechanism rather than duplicating device-assignment logic — smallest correct surface area, not a rewrite.
Session-cookie-based facility picker shown once after login when a user has more than one facility role, persisted via an httpOnly cookie that is re-validated against user_facility_roles server-side on every render (never trusted as authorization by itself), and cleared on sign-out so the picker reappears on next login. No persistent in-app switcher, no URL slug restructuring in POS. Terminal, which already has slug routing and could cheaply support a real switcher, gets the same log-out/pick-again model instead, for UX consistency across both staff-facing apps.
Alternatives considered
Multi-facility access is a rare, low-frequency scenario today. The full slug-routing retrofit is not a one-way door — it stays available later if multi-facility switching becomes frequent enough to justify a persistent in-app switcher and the routing work that requires.
Every pricing rule is now fully materialized: one logical rule is always exactly 3 `pricing_rules` rows (60/90/120 min — the only durations any booking flow in this codebase offers), tied together by a new `pricing_rule_group_id`. 'pricing_mode' ('auto' | 'fixed') is now purely a write-time/UI concern, not something the pricing function ever branches on: 'auto' means the admin enters one hourly price and the other two durations are computed and stored proportionally (recomputed on every edit, including member price); 'fixed' means each duration's price (and member price) is entered and stored independently. `calculate_member_price` was simplified back to its pre-feature shape plus one filter — an exact match on `duration_minutes` — with priority DESC deciding the cascade exactly as before. No proration math, no mode branching, and no tie-break logic exist at read time anymore. A one-time migration backfilled every existing club's pricing_rules into this shape using the exact same ratio formula the old runtime proration used, so it's price-identical for every current booking. This also sets up the OpenRally/TPS side (still separately gated, see rationale): once wired up, it can read the same explicit (duration, price) rows directly instead of running its own proration formula.
Alternatives considered
Moving the auto-calculation from read time (a formula evaluated per request) to write time (explicit rows, kept in sync on edit) collapses an entire class of read-time edge cases — gaps, tie-breaks, cross-mode priority bypass — because every scope always has exactly the rows it needs. Verified the backfill is price-identical to current production behavior for every existing club (same ratio formula, applied once instead of per-request) before relying on it. Known deferred gap: `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 slot and was NOT updated to read pricing_rules' explicit per-duration rows. A facility using non-proportional 'fixed' pricing still gets a different price quoted via OpenRally/TPS than via Setpoint admin/web for the same booking — this data-model change doesn't worsen that (verified: for every current, proportional club, OpenRally's proration is mathematically invariant to which of the 3 sibling rows it happens to pick), it just doesn't close it yet. Paused pending input from the TPS team before restructuring OpenRally's hold-time pricing (a separate Rust service with its own test suite and deploy lifecycle) — tracked as a known risk, not silently dropped.
Added a UNIQUE(academy_id) constraint on academy_facilities (confirmed with Alba: one academy always serves exactly one facility, multi-facility academies were never an intended feature) rather than patching each query site. Also closed a related gap in the academy-facility linking route: it only checked the caller's access to the academy's home facility, never the target facility being linked.
Alternatives considered
A DB constraint makes the entire bug class structurally impossible — for the 4 endpoints found today AND any future query anyone writes — instead of relying on every developer remembering to add a facility_id filter every time. All admin routes use a service-role client that bypasses RLS, so this is the only structural guard available short of a bigger RLS-enforcement architecture change (flagged as a longer-term follow-up, not blocking this fix).
Modeled as a read-only DB view (`academy_students`), not a synced table. A student has no independent business state (unlike coaches, which carry staff-assigned salary/commission terms) — it's fully derivable from existing data: a customer who was rostered on a lesson (`lesson_players`) or bought an academy package (`player_packages`). The view is a UNION of both sources grouped by (academy_id, user_id), with no trigger and no write-path coupling.
Alternatives considered
YAGNI plus this repo's existing precedent: the CRM Customer 360 page already composes data from many tables at query time with no monolithic summary table. Building a table now, before independent student-specific state (level history, progress tracking — explicitly out of v1 scope per the ticket) is needed, is speculative. Add a real table when that need is real, not before.
The recency filter (`months`) only decides POPULATION membership for the 'All Students' tab (who appears in the list, and its sort order). The private/group lesson counts, per-coach breakdown, and package history shown per student are always ALL-TIME, regardless of which tab or date-range filter is active. Discovered via code review: the first implementation applied the recency cutoff to the displayed counts too, which broke DEC-274's 'single source of truth' invariant (the Student detail page and Customer 360 Coaching tab, both unchanged from v0.35.0, are always all-time) — the same student could show different counts on the list vs. the detail page depending on the selected filter.
Alternatives considered
Cleanly separates two different concerns that got conflated in the first pass: 'who counts as an active student right now' (population, correctly recency-gated per the original ask) vs. 'how much history does this student have' (a fact about the person, not about when you're looking). Also simplified the code — the coach-breakdown query no longer needs a conditional date cutoff, and the 'Students with Packages' tab (which was silently and confusingly inheriting a 3-month cap on counts with no visible filter to explain it) needed no special-casing once counts became always-all-time.
New tables: `academy_groups` (name, level, coach) + `academy_group_members` (many-to-many roster). Programs was ruled out: it's time-bounded (start_date/end_date — the enrollment dies with the program), has no level concept, and isn't linked to packages/sessions. `GROUP-COACHING-PLAN.md` already explicitly flags persistent recurring group membership as a deferred, unsolved gap in the Programs-based workaround for group coaching — this is that gap, filled properly rather than bolted onto a model that doesn't fit.
Alternatives considered
The internal group-coaching plan already identified this exact gap and pointed at Academy-owned modeling over Programs. New, purpose-built, additive schema (2 small tables) is simpler and more correct than retrofitting a time-bounded enrollment model or overloading the lesson-roster mechanism to mean something it doesn't.
(1) Many-to-many — a player can belong to more than one group ('people from this group can be assigned to different group coachings', per Alba). No per-group exclusivity constraint. (2) Level is a free-text label staff set on the group itself; individual player_ratings are shown only as an optional reference when assigning someone, never used to gate or auto-assign placement.
Alternatives considered
Directly reflects how Alba described actual academy operations, and keeps the feature's only hard dependency on new schema (not on data-quality of an existing, sparsely-populated rating system). Because membership is many-to-many and not exclusive, per-group 'classes done/remaining' isn't tracked as a separate counter — it's derived from the player's existing package session counters (DEC-274/276 lineage), which stay correct regardless of how many groups a player is in.
Fixed (2): coach_id on group create/update is now validated against the group's academy_id — the UI already only ever offers scoped coaches, so this closes an API-level gap with zero admin-facing behavior change. Left (1) and (3) as-is: customers is an intentional, platform-wide global directory (any person can play at multiple clubs), not a per-facility table — confirmed by the pre-existing, precedented crm/groups/[id]/members endpoint, which accepts any customer.id from the global directory into a facility-scoped CRM group and even ships an add_all-every-customer option. Locking academy-groups down to prior-relationship-only customers would both contradict that established pattern and break a real workflow (adding a brand-new walk-in to a group before they have any booking/order/ticket history at the facility).
Alternatives considered
The real security boundary in this codebase's customer model is staff-facility access (requireFacilityAccess on the caller), not customer-facility history — that's a considered, precedented design, not an oversight, and academy-groups should stay consistent with it rather than becoming an outlier. coach_id is a narrower case where tightening was free and safe.
Partially reversed. Split the ~22 findings into two buckets by risk shape, not by DEC-279's original all-or-nothing framing: (A) broad partial-match BROWSE/SEARCH surfaces (customers/search, players/search typeahead, POS/Terminal/Staff-PWA search boxes, segment preview) are now facility-scoped — these let staff discover PII about a customer they didn't already know existed, which is the actual harm pattern from the original incident. (B) exact-known-identifier LOOKUPS (typed exact email during booking/guest-checkout creation, matching find-or-create-guest.ts) are left global — these require the staff member to already have the customer's exact email, and exist specifically to avoid creating duplicate accounts when linking an existing global identity to a new facility relationship. DEC-279's coach_id fix and the add_all→facility-scoped fix (crm/groups) both stand as originally decided; the only actual reversal is customers/search, which was bucket (A) all along and should have been fixed the first time rather than deferred.
Alternatives considered
The distinguishing factor is discovery vs. confirmation: a search box lets staff find out who exists at another club (the actual leak vector in both incidents); an exact-match lookup only works if staff already know the specific identifier, which is functionally equivalent to being told the answer by the customer themselves. Root-caused at the RLS layer too (customers."Facility staff can read all customers" granted unconditional global read to any owner-role account, independent of any application-layer scoping) so this class of bug is now harder to reintroduce by accident in a future feature, not just patched at each call site found today.
Per-lesson opt-in flag, mirroring the group-lesson bypass mechanism (20260619035520): lessons.availability_override boolean NOT NULL DEFAULT false; both trigger functions (lessons_check_coach_reservation, lesson_courts_check_coach_reservation) early-return when the lesson carries the flag; create_lesson_atomic gains p_availability_override DEFAULT false (old 18-param signature dropped to avoid PostgREST overload ambiguity); route-level validateLessonAgainstAvailability is skipped only when the request sets availability_override: true. UI: a Switch below the suggested times in both lesson forms (create-only, private classes with a coach); when ON, suggestions come from the class available-slots endpoint (court-only), whose group-only guard was removed since its computation never involved coach data. Court-occupancy enforcement (findCourtConflicts + bookings no-overlap) is untouched — the override can never double-book a court. Regression: TC-264 (override books 5:00 PM outside a 13:00–15:00 window and persists the flag; un-flagged API create outside the window still 400s) + TC-243 strict gate re-run green.
Alternatives considered
The flag lives on the lesson row itself, so the bypass is exactly as wide as the operator's single approved action, auditable per lesson, and default-false everywhere else — the same blast-radius shape as the accepted group-lesson bypass precedent.
No — split out. channel and business_date shipped (pos-v1.6); device_id was deliberately left out and logged as a separate task requiring a product conversation with Alba first. Investigation found no client-side 'this browser session IS device X' binding exists anywhere in apps/pos or apps/terminal checkout — the pairing API is registration-only (admin settings screen), never consumed by the actual staff checkout session. Capturing device_id for real would mean building a new staff-facing pairing/setup flow (e.g. a one-time 'which device is this?' screen per iPad), which is a genuine UX decision — how it should feel for staff, whether it's a one-time setup or a per-login picker, whether it's even worth it given Alba's clubs likely run 1-2 terminals per location — not plumbing that's safe to decide unilaterally.
Alternatives considered
Matches this session's broader course-correction after an earlier unscoped change caused real user frustration: additive, low-risk schema/plumbing work (channel, business_date) proceeded under Alba's standing trust grant for this initiative; anything that implies new staff-facing UX or behavior gets surfaced as a question first, not built silently and explained after the fact.
Extended packages/common/datetime with pure date-string helpers (shiftDateStr, monthStartStr, monthEndStr, daysBetweenInclusive) plus facilityDateRangeBoundaries (an inclusive-end sibling to facilityDateBoundaries, matching the .gte/.lte query convention these pages already use, vs. reportingDayForOffset's exclusive-end convention for single-day .lt queries). Rebuilt buildPreset/computePeriod/getDateRange on top of these instead of Date object arithmetic. For the two files where 'today' recomputes on every render anyway (category-sales, sales-trends, both terminal report pages) this was a straight swap — no reactivity gap, since the timezone hook's value flows through the very next render. For the three files with the lazy-useState default (Sales dashboard, Item Sales, Cash Drawers), added a one-shot useEffect keyed on the resolved timezone that re-derives the current preset once real timezone data lands, guarded by a userChangedRange ref so a viewer who already picked their own range is never silently overwritten. Also fixed the DateRangePicker's custom-range input prefill (was value.from.slice(0,10) on a UTC instant — off by one calendar day for any positive-offset timezone) using businessDateForTimezone, and corrected an unrelated pre-existing off-by-one in terminal's last7/last30 (was shifting 7/30 days back from today, an 8/31-day window, instead of 6/29, a 7/30-day window) to match pos's already-correct convention.
Alternatives considered
Same instinct as reportingDayForOffset: fix the date arithmetic once in packages/common and have every report page call it, rather than re-deriving Date math per page. The reactivity gap (lazy initial state vs. async timezone) is a real but narrow window — bounded to first paint, self-correcting, and never overwrites a viewer's own choice — so a full loading gate would be over-engineering relative to the actual risk.
New courts.covering text column with CHECK (covering IN ('indoor','outdoor','roofed')) as the source of truth, backfilled from is_indoor. is_indoor is NOT dropped — a BEFORE INSERT OR UPDATE trigger (courts_sync_covering_is_indoor) reconciles the pair in both directions with covering winning on conflict, so every legacy writer keeps working unchanged and the columns cannot drift; is_indoor is deprecated and dropped in a follow-up once all readers move. Roofed maps to is_indoor = true (covered, playable in rain), which matches how the one affected court was already stored, so no existing OpenRally consumer observes a change. OpenRally's ResourceResponse gains covering additively alongside the retained indoor boolean, with the spec's Resource schema updated and the protocol version bumped 0.3.0 → 0.4.0. The provisioning payload carries covering and the consumer edge function falls back to is_indoor when a caller predates the field.
Alternatives considered
One column carries the meaning and one trigger guarantees the legacy boolean can never disagree with it, so the migration is additive, reversible, and invisible to existing readers — while the consumer app, whose own schema has carried all three values for a long time, finally receives the state Setpoint could not express.
Read-time proportional allocation. allocateTaxAcrossItems() (packages/common/tax-allocation) splits each tax's already-charged amount across the order's items (+ one unattributed bucket for the taxable-service-charge share) using the largest-remainder method, so the split always sums to exactly the real charged amount. The Sales Taxes report's 'By item' view then reverse-derives each item's taxable-base contribution from ITS OWN allocated tax share (deriveTaxableBase — the same formula the Summary tab already uses on the order-level amount), not from the item's raw pre-discount total_cents, so the two tabs' totals agree for any order carrying a discount. Grouped by item_variation_id (falling back to item_name only when no variation exists), not item_name, so two variations sharing a display label don't merge and a mid-period catalogue rename doesn't fragment one product into two rows.
Alternatives considered
Same allocated-not-independently-computed approach every mainstream POS (Square, Toast, Clover) uses for item-level tax breakdowns — none of them recompute tax per item either, since the order-level charge is the one number that's actually authoritative. Read-time-only keeps zero risk to checkout and works retroactively on every historical order, not just new ones.
A booking-roster channel of its own, parallel to the match one and sharing none of its code paths. Outbound: a new trigger on booking_players emits booking_player_joined / booking_player_left, guarded to skip match and lesson bookings — both mirror their own rosters onto booking_players (api/admin/matches and protocol match_bookings.rs), so without the guard one added player would emit twice by two routes. Inbound: two new protocol endpoints, POST /openrally/v1/booking-players and .../remove-by-guest, addressed by email because that is what a consumer knows its players by (the same reasoning as match-bookings/cancel-by-guest). Both the admin API and the protocol now go through shared SQL functions add_booking_player / remove_booking_player, which own the rules that make a roster edit correct — the organizer link on bookings, the hand-over when the organizer leaves, the cost-split recompute — so the protocol handler does not restate any of them in Rust. Setpoint emits with no consumer-origin field and no suppression: a roster change is a fact about the booking and every subscriber hears it, including the one whose own call caused it. Loop prevention lives entirely on the consumer side, keyed on the booking-player id the protocol returns, which is the one identifier both systems agree on for a seat.
Alternatives considered
The match channel is untouched by construction rather than by care: its trigger fires only for open matches and the new one only for containers, so the two conditions are exact complements and no roster change can be enqueued by both. Putting the roster rules in SQL rather than restating them in the Rust handler is what keeps the front desk and the consumer app from computing different cost splits for the same booking. Guests with no consumer account are skipped rather than represented as ghosts — a team slot holds a real user id, and inventing a placeholder identity to fill it is how rosters start disagreeing with both systems at once.
Wrap the existing relay in Electron (print-relay/electron/*) as a second entry point alongside the unchanged CLI/systemd one, packaged Windows-only via electron-builder as an unsigned NSIS installer (per-user, no admin rights) for v1. Paired with a single paste-in 'setup code' — apps/pos's Printers settings page now bundles {apiKey, locationId, apiBase} via encodeRelaySetupCode (packages/common/print-relay-setup-code.ts) into one copyable string instead of exposing a raw key + a raw UUID to copy separately. The relay core (config.ts/api.ts/printer.ts/escpos.ts) needed one change, not zero: config.ts's `config` export was an object computed once at import time via requireEnv(), which threw immediately on Electron's first run (no PRINT_RELAY_* env vars exist before a setup code is pasted) — replaced with a Proxy that computes fresh from process.env on each property access, so importing the module no longer requires those vars to already be set, while every existing call site (`config.apiBase` etc.) is unchanged. index.ts's bare `while(true)` was extracted into an exported `runRelay(onStatus)` returning `{stop}`, guarded by `if (require.main === module)` so `npm start`/systemd behavior is byte-for-byte unchanged; Electron needed real start/stop (app quit, reconfigure) where the CLI never did. print-relay's own setup-code decoder (src/setup-code.ts) is a deliberate duplicate of packages/common's version, not an import of it: that package's exports point at raw .ts source, which apps/pos's Next.js bundler transpiles fine but print-relay's plain tsc + node runtime (moduleResolution: node, no bundler) cannot resolve at runtime.
Alternatives considered
The setup-code bundling and the config.ts Proxy fix are both small, load-bearing changes that make the 'paste one code, done' experience actually work on first run — everything else is packaging around relay logic that didn't need to change. Unsigned + Windows-only + per-user install was the fastest path to something Alba can hand a club today; signing and other platforms are additive follow-ups, not blockers.
facility_tickets.cart_json items gain a kitchenSentQty field. New RPC send_ticket_items_to_kitchen(ticket_id, location_id) computes the unsent delta per item (quantity - kitchenSentQty), routes it through a new shared helper resolve_print_station_for_item() (extracted from route_order_items_to_stations()'s inline category->routing-rule->default-station resolution, so neither duplicates the other), writes print_jobs/pos_print_tickets rows directly against the ticket (both tables' order_id becomes nullable, ticket_id added as a sibling with an order-or-ticket CHECK constraint), and writes kitchenSentQty back into cart_json. At final checkout, pos_order_items captures kitchen_sent_qty from the cart (same pattern as the existing category_id capture) and route_order_items_to_stations() is changed to filter on quantity > kitchen_sent_qty and print only the remaining delta — a ticket that never used Send to Kitchen behaves byte-for-byte as before (kitchen_sent_qty defaults to 0). kitchenSentQty propagates through every cart-mutating call site in both apps' terminal-store.ts and use-ticket-mutations.ts: quantity increases leave it untouched (that's what produces a printable delta on the next Send tap or at checkout); decreases/forced-quantity-1 operations (splits, applySplitToItem, revertSplitOnItem, bulkApplySplitToBill, revertBillSplit) clamp it down to never exceed the new quantity; cross-ticket moves/splits use a conservative rule (only carry 'sent' forward if the entire source line was already fully sent, since sent-ness is tracked in aggregate per line, not per physical unit — favors an occasional harmless duplicate print over a silently missed one).
Alternatives considered
The shared resolve_print_station_for_item() helper is what keeps the trigger and the new RPC from silently drifting apart on routing behavior — a future routing-rule feature only needs to change one place. Ticket-scoped (not order-scoped) print_jobs was the smallest change that let 'fire before payment' reuse the entire existing print_jobs/pos_print_tickets/relay pipeline unmodified. Verified end-to-end against a local `supabase db reset` fixture: multi-station routing (kitchen + bar in one Send), delta-only sends on a repeat call after a quantity increase, double-tap idempotency via the ticket row's FOR UPDATE lock, and that checkout only prints the genuinely-unsent remainder for a partially-sent ticket while a never-touched ticket prints in full exactly as before.
Ticket numbers reset to 0 whenever a facility's open-ticket queue empties (status <> 'closed', covering 'payment_requested' too, not just 'open'), rather than on any clock/calendar schedule — checked via a trigger on facility_tickets (trg_reset_ticket_sequence_on_close, 20260818040345/040343), not inside next_ticket_number() itself. next_ticket_number() stays a plain atomic INSERT...ON CONFLICT...DO UPDATE increment, so concurrent ticket creates never perform any 'is the queue empty' check and can't race each other into a false reset. The reservation-vs-reset race (ticket creation being two client round trips — RPC reserves a number, a separate .insert() makes it visible) is closed by a new create_ticket() RPC (20260818040347) that fuses both into one function call/transaction, calling next_ticket_number() internally rather than duplicating its logic. Both the increment (inside next_ticket_number(), called from create_ticket()) and the close-trigger's reset check take the same facility_ticket_sequences row lock as their first action, so Postgres fully serializes them. create_ticket() is authorization-checked via user_facility_role() (COALESCE-guarded — see alternatives, a bare NOT IN silently admits an unauthorized caller when the role is NULL) matching facility_tickets_write's exact role set (owner/admin/manager/staff), SET search_path = public, granted to authenticated only — mirroring the mark_bookings_paid precedent (20260817113409). Separately fixed a second, unrelated drift: production's facility_tickets already had idx_facility_tickets_number_open (a partial unique index, apps/terminal's error handler already references this exact name) instead of the full idx_facility_tickets_number_unique the migration history still showed — swapped outside the migration pipeline at some point. Every migration in this series is a single top-level SQL statement in its own file (6 files: drop old indexes, create the new one CONCURRENTLY, the reset function, the reset trigger, the create_ticket function, its grant) — `supabase db query --file`, the only approved prod deploy path per .claude/rules/migrations.md, errors on multiple statements in one file ('cannot insert multiple commands into a prepared statement'), confirmed empirically against every multi-statement version of these files before splitting them.
Alternatives considered
Matches how Toast/Square/Clover-style POS systems reset order numbers on session/day boundaries rather than a lifetime count, without hardcoding any assumption about a club's hours — Alba's explicit requirement. Verified locally end-to-end through the real create_ticket() RPC (not just the underlying SQL) with proper auth context (SET LOCAL request.jwt.claim.sub, matching a real owner/admin/manager/staff role): fresh-queue start, no reuse while any ticket remains active, reuse once the queue empties, multiple empty/reset cycles, per-facility independence, the truncation guard holds when a streak hypothetically exceeds 999, a payment_requested ticket retains its number and blocks a false reset until closed, a facility whose sequence row doesn't pre-exist still resets correctly instead of silently no-op'ing, an unauthorized caller (no role, and a real-but-insufficient role) is correctly rejected, 20+ simultaneous full create_ticket() calls on an empty queue produced distinct numbers with zero collisions, and 30 rounds of concurrent create+close specifically interleaved to hit the reservation-vs-reset race produced zero collisions. Confirmed via grep across apps/terminal, apps/pos, apps/admin that no code looks up a ticket by ticket_number alone before accepting non-uniqueness-over-time as safe — except apps/terminal's ticket-history page, which code review caught assumed exactly that: findMissingTickets() sorted all numbers in a day/week group together and looked for gaps, so two streaks in one group (e.g. #1-15 then reset then #1-8) could mask a real gap in one streak behind the other's coincidentally-matching numbers — fixed to detect streak boundaries chronologically (opened_at) and scope gap detection to within a streak; verified against a two-streak fixture where the old algorithm masked a genuine gap and the new one caught it. The 'Ticket Range' summary badge and cross-streak ticket-number search becoming coarser (a search can now match tickets from different streaks/days) were assessed and accepted as a UI trade-off rather than redesigned, consistent with Alba's own framing that search is primarily by customer name. check-in.ts's two failure codes (TICKET_NUMBER_FAILED, TICKET_INSERT_FAILED) collapsed into one TICKET_CREATE_FAILED per call site; a genuinely unrelated third TICKET_INSERT_FAILED usage (a separate ticket-linking update path, not a create) was correctly left alone after a typecheck failure caught the first consolidation attempt being too broad. apps/pos's create-ticket error handlers gained the same friendly 'please try again' conflict message apps/terminal already had, closing a cross-app inconsistency surfaced while this same diff touched both. All three touched packages typecheck clean; database.types.ts and both apps' vendored copies regenerated. Live browser verification intentionally skipped and flagged rather than silently omitted — local OTP/PKCE login is documented elsewhere in this repo as broken in the dev environment. Deploy to production intentionally not yet executed, pending Alba's explicit go-ahead: the repo's local-only prod-access lockdown (CLAUDE.md) predates this incident by months (authored 2026-05-31, per git blame) and this PR doesn't touch CLAUDE.md — corrected after an earlier draft of this entry incorrectly implied the lockdown was a reaction to this incident's response.
New pos_apply_stock_delta(p_target_table, p_target_id, p_delta, ...) Postgres RPC is now the single write path for every stock_qty change: a plain atomic `UPDATE ... SET stock_qty = stock_qty + delta ... RETURNING` (the row lock serializes concurrent callers — no lost update) writes the resulting pos_stock_adjustments audit row in the same transaction, so a stock change and its audit record can never diverge. Stock is deliberately never clamped to zero — negative results are allowed and flagged via a new caused_oversell column (true only when a non-zero delta actually drove the result negative, not for a zero-delta recount confirming an already-negative row) so oversells are queryable/reportable instead of invisible; the business owner's stated priority is 'track inventory so I don't lose money', and silently clamping hides that an oversell happened at all. pos_stock_adjustments is now append-only at the DB layer (a BEFORE UPDATE OR DELETE trigger, not just application convention) — 'editing' a past adjustment inserts a new correcting row linked via corrects_adjustment_id instead of mutating history. Separately, ticket-cart saves in apps/terminal no longer touch stock at all (previously ran an unaudited applyStockDiff on every save, decrementing on add and incrementing back on removal, functioning as an unaudited soft hold) — only checkout decrements stock going forward, since a saved-but-unpaid cart is a draft, not a commitment against inventory; the old dual-decrement (hold + checkout) was silently double-counting every completed sale's stock impact.
Alternatives considered
Six review passes (one high, five medium) were run against this branch specifically because it touches money-handling inventory logic for a live business — each pass found real issues the previous one missed, including a fifth stock-write call site (the generic item-edit form, which bypassed the RPC entirely as just another form field, then turned out to be missing entirely from apps/terminal's parallel variation-update loop — a silent 'Changes saved' with the DB value never moving), a pre-existing bug confirmed present before this branch touched the file (PO-receiving applied the line's new CUMULATIVE qty_received total as the stock delta instead of the actual physical increment arriving in that submission, over-crediting stock on every partial-then-continue receive — verified locally: a two-step 4-then-3 receive now correctly lands stock at 7, not 11), and a self-inflicted regression (an idempotency guard added to fix a narrow retry-double-apply window was itself worse than no guard — it silently dropped a second, independently-valid concurrent partial receive on the same PO line — removed, with the original narrow gap left open and documented rather than papered over with a broken fix). Every fix in this branch was verified with `tsc --noEmit` on both apps plus a direct functional exercise against a local Postgres instance (facility isolation, concurrency, zero-delta/NULL-coalesce edge cases, the caused_oversell semantics, and the cumulative-vs-delta receiving fix) before being committed. Deferred, not solved here: an 'absolute set' RPC variant that would close the narrower item-edit-form race (delta computed from an unlocked read before the RPC call, not from inside its own locked UPDATE) without changing pos_apply_stock_delta's signature for every other caller; running checkout's and PO-receiving's per-target RPC calls concurrently instead of sequentially (a real latency win, not a correctness fix, and reworking those loops risks disturbing the already-tested failure-collection logic); and centralizing the now ~14-site out-of-stock predicate and the duplicated stock-correction-delta-computation block into packages/common, both flagged by review as the same drift risk stock.ts's own centralization was built to prevent, but out of scope for this PR's stated goal.
Replaced the reset-on-empty-queue trigger entirely with a daily reset tied to the facility's local business day — the actual mainstream POS standard (Toast, Square, Clover, etc.): a ticket counter that counts up through the day and resets the next business day, independent of whether every prior ticket ever got closed. facility_ticket_sequences gains a nullable business_date column (existing rows get NULL, no backfill — same convention as channel/business_date on pos_orders, 20260803082518). A new shared facility_business_date(uuid) helper computes the facility's current business date — plain calendar date via `clock_timestamp() AT TIME ZONE facilities.timezone` (clock_timestamp(), not now() — see alternatives), no custom cutover hour, matching how 'today' is already defined for reporting (businessDateForTimezone() in packages/common) rather than inventing a second convention — falling back to UTC on any error (an invalid/unparseable timezone value, caught by review: facilities.timezone has no format-validating CHECK constraint, and this function had zero timezone dependency before this change, so an invalid value would otherwise be a brand-new way for ticket creation to fail). next_ticket_number() does a single atomic INSERT ... ON CONFLICT ... DO UPDATE with a CASE that resets last_number to 1 if the stored business_date differs from today's, otherwise increments — the exact same proven-safe atomic-upsert pattern the function always used pre-DEC-308; the row lock inherent in the upsert serializes concurrent calls for the same facility, so two simultaneous creates that both cross a day boundary can't both compute 'reset to 1' — verified with 25 concurrent calls producing 25 distinct numbers. Critically (see alternatives below), the computed candidate is then walked forward past any number still held by a currently-active ticket before being returned — a loop restructured (review, second pass) to compute the zero-pad format in exactly one place in the source, and the sequence row is only re-written if that loop actually advanced past the upsert's own value, avoiding a redundant write on the common (no-collision) path of every single ticket creation. The reset trigger (reset_ticket_sequence_if_queue_empty / trg_reset_ticket_sequence_on_close) is dropped FIRST, before next_ticket_number() is rewritten — not after, and not left disabled as dead code — since review caught that installing the new logic while the old trigger was still live would leave a real deploy-window race (see alternatives). create_ticket() (DEC-308) needed zero changes — it already called next_ticket_number() internally rather than duplicating its logic. apps/terminal's ticket-history findMissingTickets() (DEC-308) needed no code change but does carry a real, accepted limitation from the collision-avoidance fix — see alternatives and rationale, corrected here after an earlier draft of this entry claimed it was unaffected in a way its own test evidence contradicted.
Alternatives considered
Directly answers what Alba asked for — the real POS-industry standard — rather than continuing to iterate on custom mechanisms. Simpler than both prior candidates: no trigger, no per-close reactive logic on the close path; the reset decision is made once, inline, at generation time. Takes the facility_ticket_sequences row lock FIRST, then computes 'today' and decides reset-vs-increment only once that lock is actually held (not a single atomic upsert, corrected from an earlier draft — see alternatives), plus the forward-skip loop for collision safety. Follows facilities.timezone and the businessDateForTimezone reporting convention for what 'today' means (an earlier draft of this entry overstated this as 'reusing existing infrastructure' — corrected: the SQL is a new function, facility_business_date(), matching the TS helper's convention and exception-safe-fallback behavior, not literally calling it). Verified locally: same-day sequential increment, reset when the stored business_date differs from today, no double-reset on the very next call after a reset, the NULL-transition case for existing rows, correct facility-local (not UTC) day-boundary behavior via a deliberately UTC-vs-Bangkok-crossing timestamp, an invalid/unparseable timezone value falling back to UTC instead of erroring, 25 concurrent calls producing 25 distinct numbers with zero collisions, and — the scenarios that actually matter after the collision-avoidance fix — a stale prior-day ticket correctly gets skipped rather than colliding (single and multi-ticket stale ranges, including a payment_requested ticket blocking same as open), no redundant sequence-row write on the common no-collision path, and the migration reordering means there's never a window where the old and new reset mechanisms are simultaneously live. create_ticket() needed zero changes. KNOWN, OPEN QUESTION FOR ALBA (not silently claimed away or unilaterally decided, per two review passes catching earlier drafts understating it): when the collision loop skips a stale prior-day ticket, that day's true first number can be '002' or higher, which apps/terminal's ticket-history findMissingTickets() — keyed on literal '1' to detect a new streak — won't recognize as a boundary in multi-day groupings. A fourth review pass sharpened this from 'a display quirk' to a real false-negative risk: a genuinely missing ticket can get masked when a shifted day's numbers coincidentally fill the exact gap once merged with an adjacent day in a Week/Month/All-Time view. Ticket creation itself is unaffected — this is scoped entirely to a secondary reporting banner. Not yet applied to production — this repo's active local-only lockdown means every previous ticket-numbering deploy required Alba's explicit go-ahead before touching --linked, and this one is no different; built and fully verified against local Supabase, pending that go-ahead.
Item grouping and category resolution deliberately mirror the exact logic the Item Sales and Category Sales report pages already use client-side — apps/pos/src/queries/use-reports.ts's useFullReport groups top_items by item_name alone (not item_name + variation), and apps/pos/src/lib/resolve-category-names.ts's join path (item_variation_id -> pos_item_variations -> pos_catalog_items.category_id -> pos_categories.name, 'Uncategorized' on a broken chain) is replicated rather than using pos_order_items' own unused category_id column — so the emailed numbers can't quietly disagree with what an owner sees on those pages for the same day. Both rollups (top items, top category) share a single MATERIALIZED day_items CTE scanning pos_order_items once, rather than each re-filtering it independently. Both rankings break ties deterministically (order by revenue desc, name asc) since a bare 'order by revenue desc' is plan-dependent on a genuine tie and can flip between an identical day's resend/retry with no underlying data change.
Alternatives considered
Verified with two functional tests run directly against local Postgres after every structural revision (a populated day with hand-computed expected totals/rankings/category, and a zero-orders day confirming top_items: [] and top_category: null rather than an error or malformed JSON) plus a dedicated tie-determinism test (two items/categories seeded to an exact revenue tie, RPC run 5 times in a loop, output byte-identical across all 5 runs) — all passed unchanged across the CTE-scan consolidation, the multi-target-assignment simplification, and the later authorization check. The authorization check itself was verified against four scenarios in explicit transactions with SET LOCAL request.jwt.claim.role/sub (rolled back, no data persisted): a service-role caller (succeeds, any facility), an authenticated user with a real role at the target facility (succeeds), an authenticated user with no role at the target facility — the actual cross-tenant attack this closes (rejected with not_authorized), and a caller with no JWT claims at all (rejected). Six /code-review passes ran on this branch in total; each surfaced real, previously-missed issues (duplicate table scan, non-deterministic tie ordering, the facility.name/payment-method escaping gaps, a deploy-ordering crash risk, and finally this authorization gap) rather than restating a prior pass's findings — the last two passes in particular (deploy-ordering, authorization) were more severe than anything caught in the first three, a reminder that diminishing-looking findings don't reliably predict the next pass finds nothing. Not yet built: an automated equivalence test asserting the RPC's item/category numbers match the Item Sales/Category Sales report pages for the same seeded day — today that correspondence is enforced only by matching source comments on both sides, flagged by review as a real (if lower-priority) gap the same class as pos-v1.7's undocumented e2e coverage. Also flagged but explicitly out of scope for this PR: the PUBLIC-execute-grant-on-every-function pattern discovered while investigating the authorization gap appears project-wide (confirmed on create_ticket and mark_bookings_paid too, not just this function) — those functions remain safe because their own in-function checks are the real gate, but the pattern is worth Alba's awareness as a standalone question (is this project's default_privileges configuration intentional?) rather than something to change unilaterally inside an Item Sales feature PR. A live end-to-end send through the actual edge function was intentionally skipped rather than silently assumed: supabase/functions/_shared/send-email.ts calls Resend's real API directly with no local-sandbox mode, so invoking it locally would risk emailing whatever real recipient_emails are configured for a facility in local Supabase, not a safe no-op the way Mailpit/Inbucket would be.
Standardized all 13 report pages (the original 12, plus the dashboard index) on one already-existing shared component (apps/pos/src/components/ui/date-range-picker.tsx) instead of building a second one — it already had presets plus a genuine custom range (two date inputs), just wasn't wired into 9 of the 13 pages. Extended it with two presets (last52weeks, thisYear) sales-trends needed, and a new rangeGranularity() helper that derives hour/day/week/month chart bucketing from a range's actual span rather than from which preset produced it, so a custom range buckets sensibly with no extra per-preset case — with last52weeks/thisYear explicitly special-cased, since both span ~365 days (a span-only rule can't tell them apart) but want different granularities by design. Extracted a shared useReportDateRange(defaultPreset) hook owning the range state, timezone lookup, and the resync-on-timezone-change effect, replacing a userChangedRange-ref pattern duplicated across every page (originating in item-sales.tsx, copy-pasted into every later page) that had a real bug: once a user touched the range once, the effect permanently stopped resyncing to a LATER genuine timezone change (a location switch — LocationSwitcher lives in the shared dashboard layout, reachable from any report page at any time), silently pinning query boundaries to a stale location's midnight. The single shared hook fixes this once, for every page, rather than in 13 separate copies.
Alternatives considered
Verified via tsc --noEmit (clean, exit 0 across apps/pos and packages/common) after every commit, plus targeted Node/tsx repros for every non-obvious date-math fix: shiftMonthStr's leap-day clamp (Feb 29 in a leap year -> Feb 28 the year before, not a silently-rolled-over March 1st), the computeComparison rewrite's genuine timezone-independence (ran the real datetime.ts module under two different process-local TZ values and confirmed byte-identical output), the custom-range future-date clamp against all four edge cases (including a bug in the clamp's own first version, caught by a later review pass — clamping only `to` before ordering let an unclamped future `from` swap into the `to` position), the month-bucket cross-year fix, and the weekLabel/shortDate UTC-offset fixes (verified against both a negative-offset and a UTC+13 timezone). Seven /code-review passes ran on this branch — each surfaced real, previously-missed issues rather than restating a prior pass's findings, including two regressions in this PR's OWN earlier review-fix commits (the custom-range clamp bug just described, and a chart-bucketing utility that reintroduced the local-vs-UTC-getter bug class in a new spot right after fixing it in another). Not fixed here, documented instead: chartData in sales-trends still aligns mainSeries/compSeries by array index, not calendar date — pre-existing, not introduced or fixed by this PR, and a real fix means restructuring chart-data assembly, broader scope than this PR's stated purpose. Live browser verification intentionally skipped and flagged, consistent with this repo's documented constraint (local OTP/PKCE login is broken in this dev environment) — same gap Phase 2's DEC-311 already noted.
Replaced the all-or-nothing carry-over with a min-based allocation for whole/partial-quantity moves: the destination gets min(movedQty, sourceSentQty) of the sent credit, treating the already-sent units as the ones that moved first when the split is ambiguous (aggregate tracking can't know which physical units were fired). Paired with a matching change on the source side (apps/terminal/src/stores/terminal-store.ts's reduceCartItemQty): instead of clamping the source's remaining kitchenSentQty to Math.min(kitchenSentQty, newQuantity), it now subtracts the moved amount via Math.max(0, kitchenSentQty - qtyToRemove). The two formulas are provably conservative in every case: destination_sent + source_remaining_sent always equals the original sourceSentQty exactly, whether the moved quantity is less than, equal to, or greater than the sent quantity. useSplitItemToTickets (N-way cost split of ONE physical item into separate quantity-1 shares, apps/terminal and apps/pos) is a structurally different operation from a quantity move — it doesn't distribute divisible units across destinations, it cost-divides a single item, so 'was it sent' is one yes/no fact about that one item that must be answered identically for every share. A first attempt applied the move flow's running-counter allocation here too and only got it right for the first target's share, silently reproducing the exact double-print bug for every subsequent share of an already-fired item — caught by code review before merge. Fixed by computing a single uniform baseFullySent = kitchenSentQty >= quantity check once per split and applying it to every share (in both the mutation loop and the matching store action applySplitToItem, which previously computed its own independent Math.min(kitchenSentQty, 1) and could disagree with the mutation's decision for the share kept on the splitting ticket). Also fixed a related UI bug in split-bill-modal.tsx surfaced by the same review: the 'Move to ticket' / 'Split cost' action toggle only ever set itemAction, never clearing the OTHER selection state (itemAssignments for move, itemSplitIds for split) — so switching an item's action after picking a target left both maps populated, and handleAssignItems derives moveEntries/splitEntries independently, so a single Apply click could fire both mutations for the same item onto two different destinations. Centralized both writes behind one setAction(lineItemId, action) helper that always clears the inactive map, mirroring this file's existing resolveItemAction() precedent for keeping action-state derivation in one place; assignItem and toggleSplitTarget (the two lower-level setters a future caller could reach directly, bypassing setAction) were also given the same clear as defense in depth, since the whole point of this bug class is that convention-only enforcement had already failed once. apps/pos's whole-item move functions (useMoveItemToTicket, its own useBatchMoveItemsToTickets) needed no change — that app has no partial-quantity move at all, so carrying kitchenSentQty over unconditionally via ...item is already exactly correct. A second review round on this same branch surfaced two more real gaps, both fixed: (1) split-move-modal.tsx (the single-item split/move modal, apps/terminal and apps/pos) passed the mutations a `item` prop captured once when the modal opened, while the paired store actions (reduceCartItemQty, applySplitToItem) read live cart state — on a multi-terminal ticket with no realtime sync, a concurrent device's kitchenSentQty change during the modal's open window would desync the two, losing or duplicating sent credit exactly like the original bug. Fixed by re-reading the live cart line from the store immediately before each mutation call, with a graceful bail-and-toast if the line was removed entirely in the meantime. (2) The split-flow's baseFullySent formula and applySplitToItem's matching kitchenSentQty ternary were each duplicated verbatim across apps/pos and apps/terminal (4 call sites total) — extracted into a shared splitShareKitchenSentQty(kitchenSentQty, quantity) helper in packages/common/src/kitchen-sent-qty.ts, following the existing isTicketNumberConflict (ticket-errors.ts) precedent for exactly this kind of cross-app duplication. A third review round found the second round's own fix incomplete in three places, all now fixed: (a) split-move-modal.tsx's new getLiveItem() used `.find(...) ?? item` — when the live line was genuinely gone (id no longer in cart, e.g. from a double-tap firing the mutation twice, or another device having already fully moved it), `.find()` correctly returned undefined but `?? item` silently substituted the stale positive-quantity prop right back in, defeating the very 'item no longer on ticket' guard written to catch that case. Fixed by extracting findLiveCartItem(cart, itemId) into packages/common/src/cart-line-item.ts, which returns null (not a fallback value) for both 'not found' and 'quantity drained to zero' — callers can no longer accidentally resurrect stale data through a fallback operator, and the identical helper replaces the duplicated getLiveItem() logic in both apps' split-move-modal.tsx. (b) apps/pos's bulkApplySplitToBill (the 'split bill evenly' bulk action, a sibling of applySplitToItem that also collapses a line to quantity 1) still used the old Math.min(kitchenSentQty, 1) instead of splitShareKitchenSentQty, reintroducing the exact silently-wrong-for-a-partially-sent-multi-quantity-line bug applySplitToItem had already been fixed for one function away — apps/terminal's equivalent bulkApplySplitToBill needed no change since it deliberately preserves quantity (comment: 'preserves inventory counts') and so never touches kitchenSentQty at all. (c) split-bill-modal.tsx's handleAssignItems was flagged as sharing the same staleness risk as split-move-modal.tsx — checked and found not applicable: its cartItems comes from a live `useTerminalStore((s) => s.cart)` selector (re-rendering the component on every cart change) rather than a frozen prop, and moveEntries/splitEntries are derived synchronously from that fresh cartItems before any await, with `.filter(Boolean)` already dropping any item that fails to resolve — no fallback-to-stale-data path exists there to begin with.
Alternatives considered
Directly fixes the reported incident: traced the exact mechanism (checkout builds kitchen_sent_qty from the cart line's kitchenSentQty; the move/split mutations were feeding it wrong data for partially-sent lines) rather than patching the symptom at checkout time. The move-flow's min/max allocation is provably correct, verified by hand-tracing the conservation identity. The split-flow's uniform allocation is provably correct only for the common case (base item fully sent, fully unsent, or quantity=1); for a multi-quantity base item that's only partially sent, splitShareKitchenSentQty(kitchenSentQty, quantity) marks EVERY resulting share unsent (0 < S < Q never satisfies S >= Q), which under-conserves — an ordinary 2+-quantity line split after a partial kitchen fire re-fires a duplicate for the portion already made, reproducing this decision's own reported incident via the split path instead of the move path. This gap was already flagged above as 'a real, narrower, pre-existing ambiguity ... accepted as-is' rather than resolved; this rationale previously overstated that same gap as 'provably correct' without qualifying it. The exact fix (per-unit tracking, DEC-314) remains the only way to close it precisely, since the aggregate-count schema genuinely cannot tell which of a multi-quantity line's physical units were the ones already fired. Industry comparison: commercial POS/KDS integrations (Toast, Square for Restaurants, Lightspeed) generally treat 'sent to kitchen' as an append-only fire event per station routing, not a single mutable aggregate count on the cart line — i.e. the per-unit/per-fire-event tracking this decision's first rejected alternative describes as 'the actual root architectural fix, not built here' is the real industry-standard shape, not a hypothetical gold-plating option. This decision's min/max + uniform-split conservation approach is the correct fix within the aggregate-count schema this codebase already has, not a claim that the aggregate schema itself is best practice — consistent with treating the per-unit rewrite as a real, larger follow-up rather than dismissing it. Seven review rounds ran on this branch, each surfacing real, previously-missed issues rather than restating prior findings: the first caught the running-counter split bug and the dual-assignment modal bug before merge; the second caught the stale-prop conservation gap in split-move-modal.tsx and the cross-app formula duplication; the third caught that the second round's own `?? item` fallback silently defeated its own new guard, plus one sibling call site (apps/pos's bulkApplySplitToBill) that had the identical pre-existing formula bug but wasn't part of the original diff so hadn't been swept up in the first fix; the fourth caught that apps/terminal's split-move-modal.tsx handleSplit never persisted its local mutation to the DB at all (unlike handleMove, right above it in the same file) — the correctly-conserved split math from round 1 was real but only ever lived in-memory until some other, unrelated save happened to fire, silently reverting on any earlier reload — plus a genuine DRY gap in split-bill-modal.tsx's three independently-written copies of the same map-clearing rule, and a dead unused hook; the fifth caught that split-bill-modal.tsx's handleAssignItems computed BOTH moveEntries and splitEntries as one pre-await snapshot at the top of the function, then only consumed splitEntries after moveEntries' own awaited network round trips (batchMove.mutateAsync, saveSourceTicket.mutateAsync) completed — real time in which a concurrent edit could make splitEntries' captured item/price/kitchenSentQty stale by the time it was actually submitted, plus a parallel staleness bug in the moveEntries loop's own remove-vs-reduce branch decision (`movedQty >= entry.item.quantity`), which used the same pre-await snapshot for a decision made after the await. Fixed by moving splitEntries' computation to read live store state immediately before its own use (after moveEntries' awaits, not alongside them), and by replacing the branching remove-vs-reduce decision with an unconditional reduceCartItemQty call — that function already reads live cart state inside its own set() callback and already drops a line once its quantity is subtracted to zero or below, so the branch was both redundant and the actual source of the staleness. The fifth round also deduped the two apps' now-identical getLiveItem()-plus-guard-and-toast block into one local requireLiveItem() helper per file, removed a fully dead useMoveItemToTicket export (zero remaining callers after the fourth round's cleanup), and routed handleByItemsCreateTicket through the existing assignItem/toggleSplitTarget primitives instead of raw state writes that only stayed safe by coincidence. The sixth round caught that the exact same branching remove-vs-reduce staleness bug fixed in handleAssignItems (round 5) had an unfixed sibling in split-move-modal.tsx's own handleMove — the identical pattern, missed because it lives in a different file; fixed with the identical unconditional-reduceCartItemQty simplification. It also caught that handleAssignItems' moveQty (`qtyToMove[id] ?? 1`) was never clamped against the item's own quantity before being sent to batchMove/reduceCartItemQty, unlike handleMove's existing `Math.min(qtyToMove, liveItem.quantity)` — fixed with the same clamp. And it caught that a multi-item split (2+ items split in one Apply click) captured all entries' item/price data in a single pre-loop snapshot, so entries after the first could go stale during earlier entries' own awaits — fixed by moving the per-item live read inside the loop, one iteration at a time. Two smaller CLAUDE.md-alignment fixes rode along: a silent `.filter(Boolean)`-dropped null case in splitEntries got the justifying comment the standard requires, and a `movedIds`-based filter that had become dead code (reduceCartItemQty already drops zero-quantity lines from the store itself) was simplified away. Both the fourth, fifth, and sixth rounds also surfaced several candidates investigated and found NOT to be bugs, or genuinely out of scope, after reading the actual call sites (documented in alternatives above) — apps/pos's split-move-modal not eager-saving, the `?? item` pattern in three other modals, two more Math.min clamps, parallelizing DB writes, a stale-comment-vs-code default value question that's a product call rather than a bug, a stepper's display-only staleness, and further DRY extraction with low remaining value. Two genuinely separate, pre-existing issues surfaced in review were deliberately NOT fixed in this PR and are flagged here for a follow-up, since they're unrelated to kitchen-sent-qty conservation: apps/terminal's applySplitToItem/bulkApplySplitToBill don't set discountLocked: true on the resulting share the way apps/pos's do (a cross-app discount-engine inconsistency, not a printing bug), and revertBillSplit assumes every split line collapsed to quantity 1 and its price math assumes the pre-split quantity is still recoverable — neither holds in general. Corrected in the 7th review round: an earlier version of this entry called apps/terminal's exposure 'hypothetical', reasoning that bulkApplySplitToBill there preserves quantity so revertBillSplit's quantity=1 assumption would just never be triggered — traced against the actual UI (split-bill-modal.tsx's Evenly panel wires both bulkApplySplitToBill and revertBillSplit to Split/Revert) and found that's wrong: it's directly reachable today, not a future-feed hypothetical. In both apps, undoing an 'Evenly' split on a multi-quantity line silently collapses it to quantity 1, permanently losing the extra units (real inventory/kitchen-fire loss, not just a price display glitch) — apps/pos is not uniquely affected here after all. This needs its own fix (most likely: persist original quantity in splitInfo so revert can restore it), scoped and reviewed separately from this kitchen-printing PR — the fix itself is unrelated to kitchenSentQty conservation and this PR did not touch revertBillSplit or bulkApplySplitToBill's quantity handling. Also in the 7th round: two now-redundant `.filter((li) => li.quantity > 0)` calls in split-move-modal.tsx (missed when the identical pattern was simplified out of split-bill-modal.tsx in round 6) were cleaned up the same way — reduceCartItemQty/applySplitToItem/removeFromCart already leave the store free of zero-quantity lines, so both were always a no-op filter. A further candidate — split-item-detail-modal.tsx's own `?? item` fallback potentially feeding a stale quantity into its cross-ticket sibling-sync writes — was investigated and left unfixed: it requires the modal to stay open across a full cart/ticket reload elsewhere in the same session (id churn, not just a value change), a narrower and less certain trigger than the bugs actually fixed this round, and touching this file means expanding into code this PR has now explicitly declined to modify across three separate review rounds (4, 6, 7) for different reasons each time — flagged here rather than opened as a fourth investigation. Checked both apps per the standing cross-module-consistency rule throughout — every fix in this decision was verified against apps/pos as well as apps/terminal, and applied identically wherever the same code shape existed in both (and explicitly confirmed as inapplicable, not just skipped, where the shape differed). All touched packages (apps/terminal, apps/pos, packages/common) typecheck clean. Pure client-side cart logic — no database migration, no production deploy sequencing question; ships via the normal merge-triggers-Vercel-deploy path once reviewed.
Rather than build an allocation formula that guesses which physical unit was already fired (evaluated and rejected — even a numerically-conserving formula would have to fabricate a false 'already sent' label on shares that were never actually sent, misleading staff and risking the opposite failure, a silently missed order, exactly the outcome DEC-314's own stated philosophy ranks worse than an occasional duplicate print), block the ambiguous split outright. New packages/common/src/kitchen-sent-qty.ts export isSplitBlockedByPartialSend(kitchenSentQty, quantity) returns true only when quantity > 1 AND 0 < kitchenSentQty < quantity — the exact case splitShareKitchenSentQty can't resolve safely. Wired in at both of the two UI entry points that can reach useSplitItemToTickets/applySplitToItem (the 4 call sites DEC-314 already covers: apps/terminal and apps/pos's split-move-modal.tsx handleSplit, and apps/terminal's split-bill-modal.tsx by-items loop — apps/pos's split-bill-modal 'by-items' mode is a whole-item move, not a cost split, so it doesn't call this mutation at all and needed no guard) — each shows a toast telling staff to send the rest to the kitchen (or send none of it) before splitting that item's cost, and skips/aborts the split rather than silently proceeding. A follow-up /code-review pass on this same decision's own diff caught that apps/pos's bulkApplySplitToBill ('evenly' bill split, apps/pos/src/components/terminal/split-bill-modal.tsx's handleSplitEvenly) was initially left unguarded on the theory that it was a different, bulk, whole-cart operation with its own unresolved product semantics — wrong: it collapses every cart line to quantity 1 via the same splitShareKitchenSentQty call (packages/common/src/kitchen-sent-qty.ts) as the by-items path, and does it in place on the CURRENT ticket, so a multi-quantity partially-sent line collapses straight to unsent and the very next Send to Kitchen tap or checkout on that SAME ticket re-fires a duplicate — no second ticket or delay required, a strictly easier trigger than the by-items case. Fixed with the identical guard: handleSplitEvenly checks every cart line via isSplitBlockedByPartialSend before calling bulkApplySplitToBill (only relevant when 'include current ticket' is on) and blocks the whole evenly-split with a toast naming the item if any line is ambiguous, rather than partially applying it. apps/terminal's own bulkApplySplitToBill still needed no equivalent check — it deliberately preserves quantity (comment: 'preserves inventory counts') and never touches kitchenSentQty at all, so it was never exposed to begin with.
Alternatives considered
Chosen over the conservation formula because it eliminates the ambiguity instead of resolving it with a guess that can mislabel a share's true kitchen status — consistent with DEC-314's own 'no silent errors' framing and this repo's engineering-mindset bias against patches that trade one failure mode for a subtler one. Scoped tightly to the two entry points DEC-314 itself already identified as risky (useSplitItemToTickets/applySplitToItem's 4 call sites), explicitly declining to extend into bulkApplySplitToBill's different bulk-operation semantics without a dedicated look — matching this repo's standing rule to not fix nuanced product-behavior questions silently. Both apps/terminal and apps/pos typecheck clean with the guard in place. Two further /code-review rounds on this same diff caught real gaps, both fixed: (1) apps/pos's evenly-split (handleSplitEvenly in split-bill-modal.tsx) was initially left unguarded on the theory that it was a different, whole-cart operation — wrong, it collapses the current ticket in place via the identical splitShareKitchenSentQty call and can re-fire on that SAME ticket's own next Send/checkout, a strictly easier trigger than the by-items case; fixed with the identical guard, checked once fast (before the network round trip, to avoid needlessly charging other tickets) and once more with a live read right after it (the established DEC-314 discipline: re-read live state immediately before any post-await write). (2) All three by-items/single-item split call sites (apps/terminal and apps/pos's split-move-modal.tsx handleSplit, apps/terminal's split-bill-modal.tsx by-items loop) had the same gap one level deeper: the block check ran once before splitItem.mutateAsync's await, but applySplitToItem itself reads live cart state at call time — a concurrent device sending part of the same item to the kitchen during that specific network round trip could pass the pre-await check and then get silently mismarked unsent by the post-await write, reproducing the bug through a race instead of a static state. Fixed the same way: a second live-read check immediately before each applySplitToItem call; if it now fails (the race actually landed), the code does NOT retry or roll back the other tickets' shares (already correctly written from the pre-await snapshot at that point) — it tells staff explicitly that the other tickets got their share but this ticket's own copy was left unsplit, and closes rather than leaving retryable state that could re-charge the other tickets a second time. Deliberately NOT chased further: apps/pos's evenly-split has one residual gap in this same race family — if the post-await re-check trips, the function skips the current ticket's own bulkApplySplitToBill collapse (correctly avoiding a duplicate kitchen fire) but the other tickets' shares from splitBill.mutateAsync have already been charged, so the total collected across all tickets can briefly exceed the original bill total until someone notices the toast and manually reconciles. Closing this exactly would need either locking the ticket for the duration of the split or a real rollback of an already-committed separate-ticket charge — a distributed-transaction problem nothing else in this codebase solves, for a window measured in the length of one network round trip on an action (splitting a bill) that's already infrequent relative to kitchen sends. Flagged here rather than fixed, matching this file's standing preference for documenting a genuinely narrow, low-frequency gap over building disproportionate machinery for it — revisit only if it's ever actually observed in production. A third round caught one more real gap in the same family: apps/terminal's by-items loop had a silent-drop case its sibling split-move-modal.tsx files didn't — if the cart line vanishes between the pre-await and post-await checks (id no longer found), neither the block-reason branch nor the apply branch fired, so the other tickets' already-committed share went unmentioned with zero operator feedback, violating this repo's 'no silent errors' rule. Fixed with an explicit toast in that case. A structural suggestion from the same round — enforce the partial-send block once inside the mutation/store layer (applySplitToItem, useSplitItemToTickets, bulkApplySplitToBill) instead of requiring every UI call site to correctly hand-roll a pre-await-plus-post-await guard — is correct and would remove exactly this class of per-call-site drift, but is a real refactor of the mutation layer's shape, not a bug fix; logged here as a follow-up rather than done inline on a branch that's already gone through 8 review rounds chasing progressively narrower races. Revisit if a 5th call site is ever added or one of the existing four needs to change.
Pure UX/labels restructure with zero schema changes: pos_catalogues stays the table, 'Group' is only the user-facing word. Sidebar becomes Inventory → Items list (/items, the flat list, now the primary surface) + Groups and categories (Groups=/menus, Categories=/items/categories) + Stock counts + History, with Purchasing (Purchase orders/Vendors/Pending restocks) as a sibling section. Item modal: Group and Category are both required (zod min(1)); category options are a single flat, stably-keyed list scoped to the selected group (kills the Radix remount), plus an explicit guard ignoring onValueChange('') as defence-in-depth; the group can only be picked from existing groups — groups/categories are created only on their own pages (category creation/edit now requires a group and writes pos_catalogue_categories). Cost price is a visible field next to Price bound to the existing unit_cost column, persisted in create+update. Taxes: new form-state-driven ItemTaxesSection (works in create AND edit; replace-all write to pos_item_charge_exclusions on Save), and resolveCatalogueCharges/calculateTaxes gain taxBaseDeductions so per-item exclusions on apply_to_all taxes subtract those lines from the order-wide base — mirrored identically in apps/pos and apps/terminal (both checkout dialogs, terminal cart-sidebar, both use-order-mutations). Items list gains Add item, client-side search across name/SKU/GTIN, status/stock/type/price-range filters, and SKU/Cost/Stock columns.
Alternatives considered
The critical fix (category wipe) was diagnosed with a live Playwright repro before any code changed: form reset provably set the right category_id, then Radix fired select-onValueChange with val:'' — so the fix targets the actual mechanism (stable option identity + ignore-empty guard + stop fetching all facility items when no group context) rather than the write path. Making Group/Category required matches how reports and the terminal grid consume the data (both break on null category), and pushing group/category creation to their own pages keeps the item form a pure consumer, per Alba's requested flow. taxBaseDeductions extends the existing overrides pattern symmetrically instead of special-casing VAT, keeps taxable service charges in the base (an item exclusion only removes that item's own line), and is covered by pure-function tests plus an end-to-end create-item test asserting the exclusion row lands in pos_item_charge_exclusions. Silent-failure inserts on pos_item_catalogues (create and update paths) were given explicit error throws while touched, per the no-silent-errors rule. Verified by a 5-test Playwright suite (nav structure, edit-keeps-category regression, required-fields + cost + tax-toggle create flow, filters, category-requires-group) plus 5 tax-math lib tests — all green against the local stack.
Three layers. (1) Schema: partial unique index pos_drawer_sessions_one_open_per_facility ON (facility_id) WHERE status='open'; the same migration snapshots all currently-open sessions (with ledger-implied expected cash) to _snapshot_drawer_sessions_20260901, then auto-closes superseded ones with expected/actual/difference NULL and an 'Auto-closed: superseded by a newer session' note. (2) Server: startDrawerSession rejoins an existing open session (returns reused:true, audit-logs the attempted float, does NOT apply it — the cash is usually already tracked and applying would double-count) and handles the 23505 race by rejoining the winner. (3) Client: useActiveDrawerSession throws on error so React Query keeps last-known data; the gate and drawer page render explicit 'can't reach server — retry' states instead of the start form or a computed ฿0; End Session is disabled while transactions are unloaded; the drawer sign math is unified in lib/drawer.ts (DRAWER_TXN_SIGN) shared by the UI and endDrawerSession.
Alternatives considered
The invariant belongs at the schema layer (architecture-first): the whole app model — active-session lookup, recordDrawerCashSale, the payment-method-edit RPC — already assumes one open session per facility. Known accepted consequence: payment-method edits on orders from an auto-closed session window now fail with drawer_session_closed, identical to orders from any normally closed shift. Migration verified in a rolled-back transaction locally (superseded session closes, newest survives, duplicate insert rejected) before applying.
Four-part build, one PR. (A) Restore apps/pos/(dashboard)/items/discounts/page.tsx as a port of the terminal copy (nav under Inventory next to Groups and categories), fixing hardcoded '$' (table page) and '£' (discount-modal.tsx formatPrice + min-spend helper copy) with useAuth().currencySymbol / useFormatPrice. (B) Add a 'Preset discounts' section to DiscountPickerModal fed by useGeneralDiscountPresets(); PendingDiscount gains optional presetId, and both checkout-dialog discountKind call sites (single + split payment) write 'preset' when set — 'cashier_adhoc' stays for free-typed, 'group_variation' precedence unchanged. (C) Thread catalogueId from pos_item_catalogues (added to the list-query select; first-by-position link wins via new primaryCatalogueId() in lib/discount-utils.ts) through CartLineItem/addToCart at all six add-to-cart call sites in both apps, into buildResolverLines' catalogue_id. (D) New pos_discount_approvals table (code_hash sha256, expires_at now()+10min, attempts max 5, consumed_at single-use, SELECT-only RLS for elevated roles) + SECURITY DEFINER verify_discount_approval RPC (row-locked, facility-membership-checked) + edge function send-discount-approval-code (verify_jwt on, caller JWT identifies requester, service role validates approver is owner/admin/manager with email, CSPRNG rejection-sampled 6-digit code, Resend email from notifications@omnigpt.co, row voided if the send fails since the email is the only code channel) + DiscountApprovalGate component wired into both manual-discount entry points (DiscountPickerModal order-level and cart-item-modal per-line), with owner/admin/manager bypassing the gate entirely and pos_activity_logs rows on request and grant. The apps/pos terminal fork gets role-hiding of its Add Discount button only — no OTP port (accepted drift; the real till is apps/terminal).
Alternatives considered
Every piece reuses the established mechanism next to it: the restored page is the byte-identical deleted page (verified against 0925748d^) with only the currency and as-any-cast fixes; 'preset' was already an allowed pos_orders.discount_kind CHECK value with a typed write path, so B needed no migration; C supplies data the resolver's SQL contract (t.target_id = v_t_catalogue) already expects; D follows send-bill-email's exact edge-function auth shape and pos_payment_edits' audit precedent, and implements the roles page's documented-but-fictional 'staff cannot apply discounts' promise. Known accepted limitations, stated in the PR: the gate is client-side UI backed by a server-verified single-use code plus audit logs — checkout does not cryptographically re-verify approval; engine discount present keeps discount_kind='group_variation' even when a preset is also applied (pre-existing single-column precedence, both layers still itemized in pos_order_discounts); walk-ins still get zero engine discounts by design; lesson/program scope domains remain authorable-but-inert (out of scope). A /code-review high pass on the branch surfaced 10 real findings, all fixed pre-merge, three of them blocking: (1) staff/cashier — the only roles that see the gate — could never list approvers because RLS restricts user_facility_roles reads to elevated roles; fixed with a SECURITY DEFINER list_discount_approvers RPC that returns elevated members with an email on file (name/role/avatar only, never the email). (2) The edge function selected customers.full_name, a column that does not exist in any migration, and swallowed the error into a misleading 422; fixed to first_name/last_name with explicit error surfacing. (3) The legacy write policies on all four pos discount-authoring tables (presets, item/category assignments, group variations) allowed role 'staff', letting gated staff author a 100%-off preset and bypass the OTP entirely; tightened to owner/admin/manager in the same migration. Also fixed: resolver catalogue hint now prefers the till's actually-selected catalogue (selectedMenuId) over the position-first link, with a catalogue_id tiebreak making the fallback deterministic; picker presets now run through applyGroupVariation like the tile path; tile-applied presets now carry presetId (and the pos fork's checkout writes discount_kind 'preset' too); approval emails say 'on an amount of' instead of mislabeling a single line as the cart total, and the ticket-modal picker passes its subtotal; preset validity windows evaluate on the facility's local calendar day instead of UTC, and layout tiles now respect the same validity check; packages/database types hand-extended with the new table and RPCs (full regen deferred — concurrent branch holds unrelated local schema drift).
An AFTER INSERT trigger on public.payments calling SECURITY DEFINER plpgsql record_academy_payment_in_pos_row(), in the same transaction as the money-truth row. An academy row (type in package/lesson, status succeeded, amount > 0, method not in credits/package/pending) writes a completed pos_orders on a new 'academy' channel (additive CHECK widening), one pos_order_items line, one captured pos_payments, and a cash_sale drawer flow for cash. Idempotent on payments.id via the existing source_id partial-unique indexes, with the order insert's ON CONFLICT gating every write below it. Reversal (payments.reverses_payment_id) mirrors the terminal's own refund flow: pos_refunds row + pos_payments.status='refunded' + pay_out drawer row; the completed order is never mutated. No facility_tickets row: tickets are open tabs with a cart_json and an academy payment has no tab — ticketless orders are first-class in the terminal Orders list and every report. No tax invoice number, and taxes/discounts zero, because none were applied. kitchen_sent_qty = quantity on the line, because the print-routing trigger resolves a NULL category to the location's default station and would otherwise fire a kitchen ticket for a package sale. A facility with no active pos_locations row skips the mirror with a WARNING naming backfill_academy_payments_into_pos(facility_id), which reconstructs it exactly.
Alternatives considered
Architecture-first over a UI or app-layer patch, following DEC-320's precedent that money-adjacent writes belong next to the mutation in plpgsql. Atomicity is the requirement — POS takings that disagree with the money table are worse than no mirror at all. The two documented degradations (no active POS location; cash with no open drawer session) are announced and recoverable, so 'no silent errors' holds without letting the mirror hold the money hostage. Verified by 15 SQL evals including a negative control, with money reconciling exactly across pos_orders, pos_payments and the accepted payments rows.
New nullable payments.reverses_payment_id FK with a partial unique index (payments_one_reversal_per_original — at most one reversal per original), agreed between the bridge and the agent owning the payments row shape. The reversal carries the negated amount and status 'succeeded', NOT 'refunded': the Collected tile in academy-payments/summary filters out 'refunded', so a reversal marked refunded would be dropped from its SUM and the tile would keep reporting reverted money as collected. With 'succeeded' plus a negative amount it nets to zero under the tile's existing sum with no code change — verified in practice, ฿32,500 → ฿25,000 on revert. pos_refunds gains a source_id + partial unique index ('academy_payment_reversal:<reversal id>') so a retried reversal cannot refund twice, matching the other pos_* tables. The recovery backfill skips reversal rows.
Alternatives considered
A reverted academy payment now looks in the POS exactly like a refund issued at the till — native equivalence, so no downstream reader branches on origin. The unique index makes a double revert impossible at the database rather than merely unlikely; an eval asserts the unique_violation fires, and the admin route maps it to a 409. The status choice is the fragile part and is now protected on both sides: this entry plus a comment on the Collected tile's filter.
New shared registry packages/common/src/payment-methods.ts (PAYMENT_METHOD_KEYS, PAYMENT_METHODS map with label/shortLabel/countsTowardDrawer/editableInOrderDetail/splitEligible/reportBucket per key, plus paymentMethodLabel()/countsTowardDrawer()/EDITABLE_METHOD_KEYS/SPLIT_ELIGIBLE_METHOD_KEYS derived exports), exported via a new './payment-methods' package.json subpath. Icons deliberately stay app-side (lucide can't live cleanly in a framework-agnostic shared package) — each app keeps its own Record<key, LucideIcon> and adds a Globe entry for 'online'. Touched ~30 call sites across both apps: checkout dialogs (new payment step + sidebar entry + split-payment support in both apps' forked checkout components), order-detail payment-method edit pickers (EDITABLE_METHODS now sourced from the registry's EDITABLE_METHOD_KEYS instead of a hand-maintained array, so a future method's editability is declared once), transactions list + filter dropdowns, customer/dashboard views, activity-log and ticket-history icons, the terminal drawer page (new 5th 'Online' tile + filter chip, grid made responsive since the tile row was a hardcoded 4-column layout), all pos back-office reports (payment-methods report, summary sub-rows, reconciliation cash/non-cash split, sales PDF — added an explicit online_total bucket to useFullReport's bucketing so 'online' doesn't fall into the other_total residual), and both Supabase edge functions' local label maps (send-daily-sales-report, business-report-html — Deno can't import the npm shared package, kept in sync by hand with a pointer comment; also fixed a real pre-existing bug found along the way, both maps were missing a 'stripe' label and would print the raw key for async payment-link settlements). Data model: pos_payments.method gets its first-ever CHECK constraint, added NOT VALID (production has been live since 2026-05-31 with zero constraint on this column, so existing rows are not pre-checked before the constraint lands — deploy cannot fail or block on unexpected legacy data; a documented manual validation query + VALIDATE CONSTRAINT step follows once distinct values are confirmed clean). 'online' behavior flags: does not count toward the cash drawer (drawer-affecting checks in both apps' checkout components and mutations were already a plain === 'cash' comparison, so this required zero code change beyond not adding 'online' to them), is editable after the fact via the existing edit_payment_method() RPC (which has no method allow-list and already handles cash↔non-cash drawer compensation generically), is split-payment eligible, and gets its own report bucket rather than folding into 'Other'. Orders record it with pos_payments.status='captured' so it flows through every existing aggregation unmodified.
Alternatives considered
The registry is the direct fix for the root cause the impact analysis surfaced (17 independently-drifting maps, not a missing 'online' entry) — consistent with the repo's architecture-first bias over a per-screen patch. countsTowardDrawer/editableInOrderDetail/splitEligible/reportBucket are modeled as declared flags rather than re-derived per screen because the drawer and edit-picker logic already existed as ad-hoc string comparisons scattered across both apps' forked checkout stacks; centralizing the semantics (even though the actual behavior for 'online' required zero changes to the cash-only drawer checks) means the NEXT payment method only needs one new registry row, not another ~30-site audit. NOT VALID on the CHECK constraint follows this repo's live-production migration discipline exactly (additive, non-blocking, explicit follow-up validation step) rather than risking a deploy failure on data nobody has audited. Not unifying the disagreeing stripe labels respects the requested scope (record-only online method) while still documenting the inconsistency for a future, deliberate pass — matching the pattern set by DEC-317/318's own accepted-drift entries. Left as explicit follow-ups, not fixed here: (1) the three disagreeing 'stripe' display labels across transactions/drawer/customer-detail screens; (2) resolve_discounts-adjacent asymmetry between pos's qr_total bucket (payment_link+stripe merged) and terminal's payment-methods report (merges by label, same practical effect but different mechanism) — both now correctly exclude 'online' from their respective residuals but were not otherwise touched; (3) apps/pos's drawer page has no equivalent tile view to extend (verified — pos has no drawer/cash-session UI of its own, only apps/terminal does); (4) the receipt QR codes pointing at non-existent /pay/{id} and /orders/public/{id} routes (found during the payment-method exploration, unrelated to this feature); (5) the CHECK constraint's NOT VALID → audit → VALIDATE CONSTRAINT sequence is currently tracked only as an inline SQL comment in the migration file, not a ticket — flagged by code review as the repo's first NOT VALID constraint with no established tracking precedent; needs a Linear ticket before/at deploy time so the validation step doesn't silently never happen; (6) both apps' checkout-dialog.tsx keep their own hand-written PAYMENT_METHODS/SPLIT_METHODS arrays (key, label, icon, order) rather than deriving from this registry — the exact highest-traffic screens the registry exists to keep in sync bypass it entirely. Verified during code-review triage that the rendered UI is nonetheless consistent between apps (tile = 'Online' short label, confirmation panel heading = 'Online payment' long label, in both forks) — this is source-level duplication, not a live display bug — but the next new method will require editing these arrays by hand in both files with no compiler signal if they diverge, same failure mode this PR's registry was built to close off one layer up. Deferred rather than refactored in this pass given the size of the already-built, already-demoed change; (7) apps/pos's online-step confirm button reads 'Confirm Payment' while apps/terminal's reads 'Mark as Paid · {amount}' for the identical action — a minor cross-fork copy inconsistency, same accepted-drift category as the stripe label differences (item 1), not fixed here. Both apps and apps/admin build clean; migration verified locally in a rolled-back psql transaction (bogus method value rejected via check_violation, 'online' accepted) before being applied to the local dev database. Code-reviewed: 3 confirmed layout/regression bugs fixed (split-payment method-picker grid in both apps' forked checkout dialogs was hardcoded for a 4-item grid and broke visually once 'online' made it 5 items; a customer-detail-modal edge case for orders with zero payment rows regressed from showing 'Other' to showing '—'), plus countsTowardDrawer() now reads the registry's own declared flag instead of a separate hardcoded string comparison, and the activity-logs payment-method badge now uses the registry (fixing the same missing-'stripe'-label bug already fixed in the two edge functions). e2e/demo spec files' duplicated TERMINAL_URL/POS_URL constants (a CLAUDE.md violation flagged by review) were consolidated into a new e2e/helpers/urls.ts, including the pre-existing demo-discounts.spec.ts.
Seven fixes, all in a follow-on migration using CREATE OR REPLACE (20260820045150 is never edited, per .claude/rules/migrations.md) plus the two HTML builders. (1) bookings.created counted every row created in the window with no status filter, so the 5-minute holds and expired carts were counted as bookings — on the seeded reproduction 31 of 34 rows were abandoned carts; now filtered to real statuses. (2) The four booking tiles were anchored to four different timestamps (created_at, start_time, cancelled_at, start_time) and read as a funnel that could never balance; split into `on_court` (everything anchored to the slot date, so played + no-shows + cancelled = bookings that had a slot) and `activity` (anchored to what people did in the window, for any future date), with the old keys preserved so the scorecard and Group email keep working. (3) lead_time gains median_days; the email shows the median first and only mentions the mean when they diverge by a day or more — on the reproduction, mean 21.0 vs median 3.0, the mean hijacked by three bookings created ~3 months out. (4) Academy filtered cancelled lessons out of lessons_held/players but not attended/coaching_paid/coach_rating, so a cancelled class's students counted in three tiles and not the other two; all now exclude cancelled. (5) `players_served` counted distinct booking holders, not players — relabelled "Customers", and a new `people_registered` counts distinct humans across bookers, listed companions, lesson students and POS customers (all four resolve to the same identity space: customers.id IS auth.users.id). (6) New per-club report window (facilities.report_open_hour/report_close_hour) that both anchors the report day (open-hour to open-hour, so a 00:30 bar sale lands in the night it belongs to rather than jumping to the next day) and supplies the occupancy denominator. (7) `cancelled` counted any row with cancelled_at set, regardless of status. Rebased on #1049 after review: the migration now sorts after the perf one (an earlier timestamp would have let it overwrite these fixes on reset), the customers block keeps the one-pass `history` CTE and the player-key partial index is rebuilt to include checked_in so it still matches; report_fx_rates is scoped per owner (owner_id, own-rows RLS) and the group email resolves rates through the subscription's creator, instead of one global table any club owner on the platform could overwrite.
Alternatives considered
Every fix was reproduced against a seeded local day shaped like the real 2026-08-24 (17 played, 21 cancellations, 31 holds/expired, 24 POS tickets) rather than argued from reading the SQL — the seeded run is what produced the 31-of-34 abandoned-cart figure and the 21.0-vs-3.0 mean/median split. Two pre-existing bugs surfaced while doing it and are fixed here: the occupancy denominator silently fell back to an assumed 16-hour day whenever operating_hours was unset — never the case for Pad Thai, whose hours are configured, but true for any club without them — and could not report that it had, because the COALESCE sat inside the AVG; the fallback chain is now explicit (report window → operating_hours → 16 h flagged as estimated) and the email says so only when it really is estimated; and the same expression computed close-time minus open-time directly, which goes negative for any club closing after midnight, exactly the configuration the new window invites. One bug was introduced by this change and caught before opening the PR: send-business-report resolved "yesterday" from the calendar date, so a club opening at 07:00 with a 02:00 send hour would have emailed a business day that was still open; it now anchors on the club's own business day. A third was found while writing the verification query itself — hours_estimated always reported false because AVG over zero rows returns NULL, not zero, and the COALESCE sat inside the aggregate where it could not distinguish "no hours configured" from "hours total 16".
Move the fetch off the send path instead of onto it. A new pg_cron job (refresh-fx-rates, '30 */4 * * *') calls dispatch_fx_rates_refresh(), which posts to a new refresh-fx-rates edge function with the shared internal secret — the same dispatcher shape as the two report jobs. The function derives, per enabled group subscription, the pairs that subscription will need (each member club's currency → its base_currency) and the owners whose rates the report resolves (created_by plus any user owning every club in the group, mirroring send-group-report's own fallback), fetches each base currency once, and upserts a row per owner+pair. The email keeps reading one local table and never makes a network call. New column report_fx_rates.source ('auto' | 'manual', default 'manual'): the job skips manual rows so an owner's pinned rate always beats the feed, and the Settings card becomes a viewer plus override rather than the only way to get a number. Providers in order: open.er-api.com (no key, daily ECB-derived mid rates), then api.frankfurter.dev; if both fail for a pair the existing row stays untouched and the pair is named in the response so the failure lands in cron.job_run_details rather than in a wrong total.
Alternatives considered
Verified end to end on the local stack before merge, not argued from the code: a two-currency group subscription was seeded, the function run against it, and each behaviour checked — an unauthenticated call returns 401; an authorised run wrote IDR→THB 0.001874 with source 'auto' from open.er-api.com; pinning that row as 'manual' and re-running left it at the pinned value (written 0, kept_manual 1); flipping it back to 'auto' refreshed it again. The group email was rendered in all three rate states: current (rate line only), five days stale (adds an amber 'rate is 5d old' note, since silence should mean current and an age should only ever appear when something upstream has broken), and missing (withholds the total and names the pair, no longer telling the reader to go and type it). The migration is idempotent — the column is ADD COLUMN IF NOT EXISTS, the check constraint is guarded on pg_constraint, and the cron job is unscheduled before being scheduled so a re-run cannot stack duplicates.
Cost price stays item-level and is now shown in every mode, not just single-variation. Alba's reasoning: variations on this catalogue are typically flavours or pack sizes of the same underlying product, sharing one real cost, not independently-sourced SKUs — so per-variation cost would be over-modelling for how this club actually uses variations. The per-item Cost price FormField's `isSingleVariation &&` gate was removed (Price's own gate is untouched — it still only renders in single-variation mode, since a single number there genuinely has no meaning across multiple variations). Taxes & charges (ItemTaxesSection) moved from its old position — after ManageInventorySection, before the Variations table — to directly under Cost price, before Description, so Price / Cost price / Taxes now read top-to-bottom as one pricing block. No schema or mutation change was needed: unit_cost was already being persisted unconditionally on both create and update paths; the bug was purely that the field was invisible while the value kept moving.
Alternatives considered
Directly reflects Alba's stated mental model of how variations are used on this catalogue, and removes a real correctness bug (a value that kept writing while invisible) rather than just re-exposing the field. Verified with new Playwright coverage (apps/pos/e2e/inventory-defaults-cost.spec.ts TC-03/TC-04): a 2-variation item's Cost price field is visible and round-trips through save, and the Taxes section renders between Cost price and Description in the DOM.
Add a sidebar facility switcher to apps/pos only, reusing the exact mechanism the post-login FacilityPicker already relies on rather than building new plumbing. The (dashboard) layout now also fetches every facility the account has a role on (only when roles.length > 1) and passes it to AuthProvider as a new `facilities` field; the previous selectFacility server action's role-check-then-cookie-write logic was extracted into a shared setFacilityCookie() helper, reused by a new non-redirecting switchFacility(facilityId, nextPath) action. The new FacilitySwitcher component (apps/pos/src/components/layout/facility-switcher.tsx) renders the existing MerchantBrand unchanged — no popover, no chevron — whenever an account has one facility or fewer switchable options, and only turns into a Popover-based switcher (mirroring apps/admin's) for accounts with more than one. On selection it does a hard window.location.assign (not a client router push), matching how the picker and sign-out already navigate, so react-query cache and any in-memory state for the old facility don't leak into the new one. Because POS's routes carry no facility slug (unlike admin's `/{facility}/...`), the destination path is picked from a static allow-list of real nav hrefs (constants.ts's resolveSwitchLanding, re-validated server-side too) — an exact match (e.g. `/items`) keeps you on the same section, anything else (a detail page with an id belonging to the facility being left, like `/customers/{id}`) falls back to `/dashboard`. Terminal is untouched: its existing pick-once-then-sign-out-to-change model stays exactly as DEC-272 left it.
Alternatives considered
Reuses setFacilityCookie's existing re-validation (the submitted facilityId is never trusted as authorization on its own) rather than adding a second, parallel authorization path — the new switchFacility action is exactly the picker's role-check-and-cookie-write, just without the redirect() (which doesn't reliably propagate out of a client onClick handler, per the same note already on signOutAction in actions/auth.ts). Verified with new Playwright coverage: apps/pos/e2e/facility-switcher.spec.ts (switcher visible only for multi-facility accounts, switching from a static route stays on it, switching from a synthetic detail-page URL falls back to the dashboard, a single-facility account renders the same plain brand row as before) and apps/terminal/e2e/no-facility-switcher.spec.ts (a multi-facility account has no switcher anywhere in the terminal shell; the only way to change facility is still signing out).
Widen payments.amount to bigint in a new migration (20260904034858_academy_payments_amount_bigint.sql), same purely-additive ALTER COLUMN TYPE pattern as the price_amount fix. payment_requests.amount_amount gets the same treatment in the same migration: process-package-billing (supabase/functions/process-package-billing/index.ts) writes a recurring academy package's price_amount directly into amount_amount for automatic billing_mode packages, and settle-payment-request.ts later copies amount_amount straight into payments.amount when the request is settled — so it carries the exact same value through the exact same package-payment flow and would overflow at the exact same ceiling. payments.bonus_amount was checked and deliberately left untouched: it is only ever written by the credit-topup flow, never by this package-payment path, so it's out of scope here. Also fixed in the same pass: the POST /api/admin/lesson-packages guard `if (!academy_id || !name || !price_amount)` falsy-checked price_amount, so a legitimate 0 (free package) was rejected as 'missing' before ever reaching isValidPriceAmount()'s explicit 0-is-valid check — inconsistent with the PATCH/edit path, which already used an undefined/null check. POST now matches PATCH. database.types.ts was regenerated for both this migration and the earlier price_amount one (it had also gone stale — a prior failed `supabase gen types` run had clobbered it down to a single-line container-error JSON blob, silently breaking every consumer's types; caught and fixed as part of this same regen).
Alternatives considered
Matches 20260904032613's own reasoning and pattern exactly — ALTER COLUMN TYPE widening only, no data rewritten, every existing int4 value is a valid int8 value, so it's non-blocking and reversible. Applied locally via `psql -f` (not `db push`/`db reset --linked`, per migrations.md) and verified in a rolled-back transaction: a payments row with amount = 2,346,000,000 (the exact reported bug value) now inserts successfully. npx tsc --noEmit clean on apps/admin; packages/common unit tests pass.
Added create_pos_order_atomic() — a single SECURITY DEFINER Postgres RPC, mirroring the existing record_academy_payment_in_pos_row() pattern — that writes the ENTIRE checkout in one transaction: order + line items + modifiers + service charges + discounts + taxes + payment(s), plus every per-order side effect that used to be a separate client-side step (stock decrement via the existing pos_apply_stock_delta, gift-card balance deduction + redemption row, the discount-engine ledger commit via the existing commit_discount_applications, the drawer cash-flow entry, and the activity-log row). Keyed by a client-generated idempotency key (crypto.randomUUID(), minted once per checkout ATTEMPT SESSION via lazy useState init in checkout-dialog.tsx and reused across every retry of the same cart, only replaced when the cart's content changes or the dialog closes/reopens) against a new partial UNIQUE index on pos_orders.idempotency_key / pos_payments.idempotency_key (previously present but only plain/non-unique, never enforced). INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING * — if nothing came back, the existing order is fetched and returned (created:false) and NONE of the side-effect steps run again, since they already ran exactly once on whichever attempt actually committed. Business-rule COMPUTATION (totals, tax, discount resolution, which stock targets need a delta) stays entirely client-side, unchanged — the RPC only makes the WRITES atomic, it never re-derives prices or re-runs the discount resolver; per-item discount-ledger/stock-target rows use item_index (the cart's line position) to let the RPC resolve the real order_item_id/document_id after creating them, since the client can't know those ids in advance. Per-effect failure tolerance for the originally-best-effort steps (stock decrement per target, discount-ledger commit, drawer flow, activity log) is preserved via BEGIN/EXCEPTION blocks (Postgres subtransactions) inside the function, so one bad stock target still can't block an otherwise-good sale — it just now happens inside the same transaction instead of via separate, silently-skippable client-side try/catch blocks. Gift-card redemption is the one exception: it is NOT wrapped in a savepoint, because a payment recorded as 'gift_card' must correspond to a real deduction or the whole order rolls back — stricter than the old code, which could complete an order claiming gift-card payment even if the balance update silently failed. Also added an explicit tenant check (p_location_id must belong to p_facility_id) inside the function, since SECURITY DEFINER bypasses RLS entirely and the facility-role check alone doesn't prove the location belongs to that facility.
Alternatives considered
Closes a live, actively-recurring production data-integrity incident (36 duplicate-order clusters since May 2026) at the root — the order write itself — rather than patching the symptom (e.g. debouncing the 'Mark as Paid' button, which would not have helped once an order was already silently created). Verified locally via direct RPC/SQL testing (fresh create, idempotent retry, atomic rollback on a mid-write failure, retry-after-failure, cross-tenant location rejection, empty-payments guard, split-payment per-row idempotency, and the successful-but-unacknowledged-retry scenario for every side effect) plus new Playwright e2e coverage in both apps (apps/terminal/e2e/checkout-idempotency.spec.ts, apps/pos/e2e/checkout-idempotency.spec.ts) exercising the real UI: a forced RPC failure retried with the same idempotency key produces exactly one order, and a dropped-response retry (RPC succeeds server-side, client never sees it) produces exactly one order AND exactly one of each side-effect row (modifier, item-level discount, tax line, drawer cash-flow entry) — not two, not zero.
The role check lives in the pos_orders_prevent_completed_mutation trigger itself (BEFORE UPDATE, fires on every UPDATE regardless of caller), not only in the void_pos_order/unvoid_pos_order RPCs. An RPC-only check would be bypassable by any 'staff' account simply calling supabase.from('pos_orders').update({status:'voided', void_reason:'...'}) directly from the client, since RLS already permits that role to UPDATE the row — the trigger is the only enforcement point that fires no matter which code path (RPC, future RPC, ad hoc client call, dashboard) attempts the write. The trigger allows exactly two new reasoned transitions (completed->voided, voided->completed/'Un-void'), each requiring COALESCE(user_facility_role(facility_id), '') to be owner/admin/manager and each field-isolated (to_jsonb diff excluding only the status/void_* columns) so a void/un-void can never double as a side-channel edit to amounts, items, or payments — and, once a row is voided, the SAME trigger now also blocks every other mutation on it (closing a gap the RPC-only design would have left: without this, a voided row would have zero protection on its very next UPDATE). void_pos_order/unvoid_pos_order stay as named RPCs anyway — not because they enforce authorization, but so the frontend has one auditable call (and so voided_by is set from auth.uid() server-side, never trusted from the client) instead of a raw .update(). Frontend role list (owner/admin/manager) is a single shared constant, VOID_ORDER_ROLES in packages/common/src/roles.ts, imported by all three order-detail-modal call sites (apps/pos, and both variants in apps/terminal) rather than a literal re-typed per file.
Alternatives considered
Verified locally end-to-end with simulated JWT sessions (set_config('request.jwt.claims', ...)) rather than argued from the code: a staff account is rejected both via the RPC and via a raw UPDATE bypassing it entirely; a caller with no facility role at all is rejected (the COALESCE fix, confirmed after finding the NULL-logic gap); a manager void without a reason is rejected; a manager void with a reason succeeds and sets voided_by/void_reason/voided_at correctly; an owner un-void restores status=completed and clears all three void fields; voiding an already-voided order is rejected. /code-review on the full diff (backend + all three UI call sites) additionally caught two real correctness gaps outside the migration itself, both fixed same-session: the void/un-void mutation hooks weren't invalidating the ["order", orderId] cache key the open detail modal actually reads (copied from the sibling useEditPaymentMethod hook, which does), so a successful void left the modal showing the stale pre-void state; and the admin CRM's per-ticket order-total query (apps/admin .../crm/customers/[id]/route.ts) had no status filter at all, so a voided order would still inflate a customer's ticket total and order count, contradicting the feature's own promise that a void is 'excluded from all sales reports' — fixed to match the sibling rawOrders query's existing .in('status', ['completed','paid','closed']) filter. The same audit found and fixed one more, pre-existing instance of that exact bug in apps/pos's customer-detail-modal.tsx (Total Spent/Avg Order/Largest Order/Top Items/monthly chart all summed the customer's full unfiltered order list — a `completedOrders` variable was already computed there but never actually used).
Reused facilities.settings jsonb (no new columns/migration) for two new keys — max_advance_booking_days (default 14) and max_courts_per_booking (default null = unlimited) — editable from a new 'Online Booking' card on the existing Settings → Operations tab (apps/admin/src/components/admin/operations-settings-tab.tsx), following that tab's established fetch/PATCH-merge pattern. apps/web's step-browse.tsx now reads the real per-facility value instead of the literal 14. Advance-window enforcement is now server-side in bookings/hold/route.ts (previously nonexistent). The concurrent-court cap is enforced at three points sharing one helper (checkConcurrentCourtLimit + getFacilityBookingSettings, new apps/admin/src/app/api/v1/_lib/booking-rules.ts): hold-time, confirm-at-venue (pay-at-club, before money moves), and payments/intent (before the Stripe PaymentIntent is created). Critically, bookings/hold/route.ts also now stamps guest_email onto a newly-created hold row immediately whenever identity is already known (a logged-in user's profile email, resolved via JWT at request time) — hold_slot() itself has no guest_email param, so without this stamp every hold stayed anonymous until confirm, meaning a customer holding a second court before confirming the first sailed straight past the check (round 5 of code review caught this as a structural gap, not just the accepted race below). The stamp closes it for any caller whose identity is known at hold time.
Alternatives considered
Lowest-effort framing honored structurally: zero new DB columns/migrations, one new settings card slotted into an existing tab using its existing save pattern, one shared helper file instead of tripling logic across three routes. But 'lowest effort' did not mean skipping correctness once real gaps surfaced — six sequential code-review rounds each found genuine bugs, the most severe in round 6: the self-exclusion guard (`!distinctCourts.has(courtId)`) was silently defeated for confirm-at-venue and payments/intent, because by the time either runs, the row being checked already matches its own overlap query (once identity is stamped on it) — so a guest already over the cap via other courts could still confirm the very one that pushed them over it, checking their own presence in the set instead of everyone else's. Fixed by excluding the checked row by its own booking id (a new `excludeBookingId` param) rather than special-casing its court id — confirmed with a live test: cap raised to 2, two courts held, cap then lowered to 1, confirming either one now correctly rejects. Round 6 also caught the JWT email resolution doing an unnecessary (and failure-prone) customers-table round trip instead of using `jwtUser.email` directly, and that the settings clamp lived only in the React form — PATCH /api/admin/facilities now re-validates max_advance_booking_days/max_courts_per_booking itself, since any other caller of that endpoint bypassed the client-side guard entirely. Every fix across all six rounds was re-verified with real curl/psql tests against the local Supabase instance, not assumed correct from a code read. What's NOT fixed — the check-then-write race, and the anonymous-guest-paying-online gap — is called out explicitly above and in-code rather than silently shipped or silently dropped, consistent with the repo's 'no silent errors' bias applying to omissions as much as to exceptions. Full apidocs/versions.ts/feature-matrix.ts docs-sync (flagged by review) deliberately deferred — this hasn't shipped as a versioned release yet, and picking a version number/feature grouping is a bigger call than this decision log entry; do that pass if/when this ships as one.
New migration (20260907104907_fix_booking_currency_default.sql): drop the hardcoded `DEFAULT 'usd'` on bookings.currency and add a BEFORE INSERT trigger (set_booking_currency_from_facility) that backfills currency from the owning facility's currency whenever a caller doesn't supply one explicitly; an explicitly-supplied currency (e.g. a genuine future multi-currency payment) passes through untouched. Fixes every current and future insert path at the single DB write boundary instead of chasing call sites. Scoped narrowly per Alba's explicit instruction not to touch anything else: no application code changed, no backfill of existing mistagged rows (flagged separately, not fixed in this pass), Darren Blakeley's separate stale Court-5 price anomaly left for its own investigation.
Alternatives considered
Architecture-first over scattered patches, per this repo's standing bias — one DB-level fix closes every existing and future call site instead of an N-site TS patch. Verified locally (psql against the shared local Supabase instance, in rolled-back transactions, no `db reset`): a booking inserted with no currency correctly backfills to the facility's THB; a booking with an explicit currency (eur) is left untouched. Code-reviewed: one finding fixed (removed a dead-code 'usd' fallback inside the trigger — facilities.currency is itself NOT NULL and bookings.facility_id is a NOT NULL FK to facilities(id), so the SELECT can never leave NEW.currency NULL; the fallback was unreachable and risked masking a real orphaned-facility bug behind a silent default instead of surfacing it) and this decision-log entry added per the same review's second finding. Renumbered from DEC-334 to DEC-336 while merging — DEC-334/335 were independently claimed by other already-merged PRs (POS atomic checkout, POS void/un-void, and this repo's own online-booking-settings feature) in the time this branch sat open.
Reuse the existing club_credits ledger. A new SECURITY DEFINER wrapper, spend_pos_credits(customer_id, facility_id, amount_cents, notes), adds the one thing the raw record_credit_transaction() primitive doesn't do itself — checking the caller holds a real staff/manager/admin/owner role at the facility being charged — then calls it with a negative amount, mirroring how create_booking_with_credits() already spends the same wallet for bookings and assign_package_with_credits() for lesson packages. The debit happens client-side BEFORE the pos_orders/pos_payments rows are created (opposite of gift_card's after-the-fact soft clamp): spend_pos_credits does a hard, atomic, row-locked balance check via record_credit_transaction, so a declined debit (insufficient_balance) never leaves a completed order on the books with no real payment behind it. A companion refund_pos_credits() reverses the debit if order creation fails after a successful debit, and useIssueRefund (terminal's only refund-creation path) now calls it when refunding a credits-paid payment, restoring the wallet balance. club_credit_transactions gets a new 'pos_payment' type (payment_id and reference_id both NULL — the order/payment don't exist yet at debit time) alongside the existing topup/booking_payment/booking_refund/package_payment types. In the shared payment-methods registry (packages/common/payment-methods.ts), credits is countsTowardDrawer:false (no cash moves), editableInOrderDetail:false (the wallet debit already happened — relabeling wouldn't reverse it), and splitEligible:false (not wired into split-payment this pass).
Alternatives considered
Two real bugs were caught by e2e testing before this ever reached prod, not by manual review: rewriting club_credit_transactions' CHECK constraints to add 'pos_payment' initially dropped 'package_payment' (added by an earlier, unrelated migration and still live via assign_package_with_credits) — every package-with-credits purchase would have started failing the moment this shipped; and club_credits' RLS only let a customer read their own balance or an owner/admin/manager read any balance, so a staff-role cashier (the role terminal checkout mostly runs under, and the same role spend_pos_credits already trusts to spend) saw a permanent "Wallet balance: ฿0.00" with the charge button always disabled. Both fixed and re-verified via apps/terminal/e2e/credits-payment.spec.ts (funded customer completes a Credits charge, wallet debited by the exact order total, no drawer transaction created, transactions list filters correctly; a second customer with an insufficient balance is blocked with no order or wallet change). A follow-on /code-review pass caught the no-reversal-on-order-failure gap and the refund-doesn't-restore-wallet gap before the PR was opened; both fixed and covered by the same migration's now-NOT-VALID pos_payments_method_check widening (matching the existing, still-unvalidated 20260902034001 constraint's own documented manual-VALIDATE-CONSTRAINT follow-up, so this migration can't fail deploy on unconfirmed legacy data either). **Post-merge note (2026-09-07):** this PR's branch was merged with main's DEC-334 (create_pos_order_atomic — atomic, idempotent checkout RPC), which lands after this decision was made. spend_pos_credits/refund_pos_credits are called from the client BEFORE invoking create_pos_order_atomic, same as the pre-existing gift-card-redemption call — see the code review triggered by this merge for whether that ordering still holds up against the new atomic-RPC checkout path. **Superseded by DEC-368 (2026-09-08):** the debit-before-order-creation ordering above does NOT hold up against create_pos_order_atomic's idempotent retry semantics — see DEC-368 for the confirmed double-debit bug and the fix (moving the debit inside the atomic RPC, mirroring gift_card). spend_pos_credits() is no longer called from the checkout path as of that fix; refund_pos_credits() remains live via the order-detail refund flow (order-detail-modal.tsx), unaffected. **Renumbered from DEC-334 to DEC-367 while merging** — this decision log had heavy numbering churn from several concurrent PRs independently claiming the same next-available number in the DEC-334-336 range as they each merged; jumped past the contested range entirely rather than chasing an exact free slot within it.
Moved the credits-wallet debit INSIDE create_pos_order_atomic's existing payment loop (pos_credits_atomic_debit migration), resolved per PAYMENT ROW from that row's own method + amount_cents plus the order's own pos_customer_id — no new RPC parameters, the same shape gift_card already uses (gift_card_id + amount_cents live on the payment row, not as separate top-level params). Calls record_credit_transaction() directly — the same primitive spend_pos_credits() wraps — rather than nesting through spend_pos_credits() itself, since spend_pos_credits()'s own auth.uid()-based staff-role check would incorrectly reject a trusted service-role caller (auth.uid() is NULL there); create_pos_order_atomic's own top-of-function role check already covers that gate for the whole checkout, the same way it already covers gift_card's direct pos_gift_cards UPDATE instead of a wrapper RPC. NOT wrapped in a BEGIN/EXCEPTION savepoint — same as gift_card — a payment recorded as 'credits' must correspond to a real debit or the whole order rolls back. checkout-dialog.tsx in both apps/pos and apps/terminal had the separate pre-order spend_pos_credits call and the catch-block refund_pos_credits compensation removed entirely; the 'insufficient_balance' exception (still raised by record_credit_transaction, now reached via create_pos_order_atomic instead of spend_pos_credits) surfaces through the existing friendlyErrorMessage() path in both apps, reusing the existing isInsufficientCreditsBalance() matcher. spend_pos_credits()/refund_pos_credits() are left in the schema (not dropped): refund_pos_credits() is still live via the order-detail refund flow (order-detail-modal.tsx); spend_pos_credits() has no remaining call site after this change but dropping a database function is not a reversible, low-risk edit, and an unused RPC left in place is harmless. A follow-on review pass (2026-09-08) additionally caught and closed: a missing amount_cents > 0 check on both the credits and gift_card branches (a crafted negative amount could top up a balance instead of debiting it), a missing mutual-exclusivity guard letting one payment row drain both a gift card and the credits wallet at once, and a stale-local-state bug in both apps' checkout-dialog.tsx where the single-payment cash-completion screen displayed the locally-held tendered/change amount instead of the server-resolved actual value on an idempotent replay (mirrored the fix already proven correct in the split-payment handler).
Alternatives considered
Closes the double-debit bug at its root (the write itself) rather than patching the symptom (e.g. disabling the retry button, which doesn't help once a genuine dropped-response case has already occurred). Verified by walking the exact scenario through the new code: create_pos_order_atomic commits (order + credits debit, same transaction) but the client never sees the response → cashier retries with the SAME idempotency key → the order INSERT hits ON CONFLICT DO NOTHING, short-circuits to the existing-order fetch, and returns before the payment loop (and therefore the credits debit) ever runs again → wallet debited exactly once, exactly one order. Verified via /code-review high (5 rounds): rounds 1-2 found the amount-sign and mutual-exclusivity gaps above (closed same-session); round 3 found the cash-completion stale-display bug (closed same-session, mirroring the already-correct split-payment fix); no automated e2e test yet exercises the credits-specific dropped-response-retry scenario directly (ticketed as a follow-up, same gap the review itself flagged) — verification for that path rests on code-walk plus the passing existing idempotency suites for every other payment method's side effects.
Added drag-and-drop to the existing Courts settings table (apps/admin/src/components/admin/courts-list.tsx), mirroring court-images-dialog.tsx's dnd-kit pattern (DndContext/SortableContext/useSortable) but for table rows via verticalListSortingStrategy. Persistence goes through a new Postgres RPC, reorder_courts() (supabase/migrations/20260909060247_reorder_courts_rpc.sql), called from a new PATCH /api/admin/courts handler — not a JS loop of individual UPDATEs (the pattern court-images uses), because that has no rollback: a mid-loop failure leaves sort_order half-written, and the client's own 'reverting' fetch would then show that broken intermediate state instead of either the old or new order. One RPC call is one Postgres transaction — always fully applied or fully rolled back. The RPC requires the payload to cover every court in the facility exactly once (by DISTINCT id) and requires every sort_order value in it to itself be distinct, rejecting the whole batch otherwise. courts-list.tsx also now updates each court's own sort_order field (not just its array position) after a successful drag, since court-form-dialog.tsx's Edit flow reads that field to seed a hidden re-save value.
Alternatives considered
Five /code-review rounds, each finding real, verified-not-assumed bugs — matching the same discipline as the concurrent-court-cap feature earlier the same day (DEC-335/336 in this log): round 1 caught the check-then-loop non-atomicity (fixed with the RPC); round 2 caught the RPC's own default-privilege exposure (the most severe finding — an unauthenticated-adjacent authorization bypass, fixed and empirically re-verified at the Postgres role level, not just re-read); round 2 also caught a duplicate-id-substitution gap in the completeness check (fixed, DISTINCT-based); round 3 was clean on the two files reviewed at that point; round 4 caught a real regression (drag then edit-and-save on any court silently reverted the whole order, since the optimistic update only moved array position, not each court's own sort_order field — fixed and reproduced+re-verified live via Playwright: drag, then open Edit, Save, confirm order held), a missing duplicate-sort_order-value check (fixed), a malformed-UUID/non-integer input path that surfaced as a raw 500 instead of a 400 (fixed with route-level regex/integer validation), and a pre-existing (not new) unhandled-rejection gap in the shared fetchCourts() helper used by every action in the file, not just reordering (fixed once at the source). Two findings accepted as documented, out-of-scope trade-offs rather than fixed: a narrow toggle/edit-vs-drag race (documented in-code, same category as the booking-cap feature's accepted race), and DRY duplication with court-images-dialog.tsx's near-identical sortable-row pattern (a real observation, but refactoring an unrelated, unmodified existing file into a shared abstraction is outside a 'quick change's scope). Full apidocs/versions.ts/feature-matrix.ts docs-sync deliberately deferred, same reasoning as DEC-335/336 — this decision-log entry is the record until it ships as a versioned release.
Bug 1: void_pos_order/unvoid_pos_order only ever touch pos_orders.status (confirmed by reading the RPC) — the report QUERIES already correctly excluded voided orders, but neither app's void/unvoid mutation hooks invalidated the report query caches, so a reports view stayed stale until an unrelated navigation remounted it. Added sales-summary/payment-methods-report/full-report invalidation to both onSuccess handlers in both apps/pos and apps/terminal's use-void-order-mutation.ts. Also found and fixed the same gap on useCreateOrder's onSuccess in both apps (a completed sale doesn't refresh an already-open reports tab either) and a genuinely missing orders.status='completed' filter in apps/terminal's usePaymentMethodsReport (apps/pos's equivalent already had it from a prior parity fix). Bug 2: new migration (fix_checkout_rpc_bigint_casts) CREATE OR REPLACEs create_pos_order_atomic with ::bigint casts on exactly the JSON fields feeding the 6 widened columns above, leaving every other ::integer cast (tax/discount/service-charge/tip amount_cents, quantity, kitchen_sent_qty) unchanged since those columns were never widened. pos_gift_cards.current_balance_cents and record_credit_transaction's p_amount are also deliberately left integer-typed (neither was widened — separate, already-documented scope decisions) but their validation casts now parse as bigint first (so an out-of-range value raises a clear exception instead of crashing on the raw cast) before narrowing back to integer, the gift-card side via LEAST()'s existing clamp (safe since it's bounded by the still-int4 balance) and the credits side via a new explicit int4-range guard.
Alternatives considered
Verified void_pos_order/unvoid_pos_order's actual column-level behavior by reading the RPC directly rather than assuming from the bug report — confirmed the report QUERY logic was already correct and the gap was purely missing cache invalidation, which changed the fix from 'audit every report query' to 'add 3 invalidateQueries calls in 4 places.' The migration's bigint-cast scope was derived directly from academy_pos_bridge_amount_bigint's own ALTER COLUMN list (6 columns, no more, no less) rather than casting everything to bigint defensively. /code-review high ran 5 rounds on the combined diff, each finding real, independently-verified issues (not assumed from the review text alone): round 1 caught a real gap in the drawer cash-flow INSERT (cash_in_cents/change_out_cents feeding the same pos_drawer_transactions.amount_cents column this migration already widens elsewhere, silently swallowed by a per-entry EXCEPTION WHEN OTHERS => RAISE WARNING block — an overflow there wouldn't even crash checkout, it would silently short the physical cash drawer); round 2 caught the missing refund-netting + missing create-order report invalidation described above; round 3 caught a genuine pre-existing gap surfaced by this diff touching the exact line — the base (non-gift-card, non-credits) pos_payments INSERT had no amount_cents validation at all, unlike the gift-card/credits branches, meaning any staff-role caller of this directly-reachable SECURITY DEFINER RPC could pass a negative amount and silently corrupt every report total; round 4 caught a missing .order() before .range() on the new refund pagination (unstable page boundaries can skip/double-count rows) and that the main pos_payments query in the new usePaymentMethodsReport had no pagination at all despite the same file adding it elsewhere for the identical PostgREST 1000-row-cap reason — both fixed, plus a payment_id tiebreaker added since created_at alone can tie. Round 5 caught the most severe issue of the whole fix: verified live against local Supabase that `.select('...orders!inner(...)')` on pos_payments 400s outright (PGRST200 — no relationship named 'orders', only 'pos_orders' exists) — a PRE-EXISTING bug (apps/pos/src/queries/use-reports.ts:268 and :401, unrelated to this PR until this diff copied the same broken pattern into apps/terminal) that meant BOTH apps' Payment Methods report has been silently 400ing in production, predating this session — the exact report this whole fix was meant to correct never actually returned data at all. Fixed by aliasing the embed (`orders:pos_orders!inner(...)`) at all 3 call sites (2 pre-existing in apps/pos, 1 new in apps/terminal), verified against the live local schema before and after. Round 5 also caught that the new blanket amount_cents<=0 guard added in round 3 would reject a legitimate $0 (100%-discounted/comped) cash or card payment — checkout-dialog.tsx's Mark as Paid buttons in both apps have no floor guard blocking a $0 submission — fixed by changing the base check to amount_cents<0 (reject negative only), leaving the gift-card/credits branches' own stricter <=0 checks intact and meaningful (a $0 gift-card/credits 'redemption' isn't a real payment); and that useIssueRefund's onSuccess (order-detail-modal.tsx) reproduced this PR's own original bug report via a different mutation — a refund flips pos_payments.status and inserts a pos_refunds row with no report-cache invalidation — fixed to match void/unvoid/create-order. Additionally added friendly-error.ts mappings in both apps for the new RAISE EXCEPTION text (previously would have shown raw internal SQL error text to a cashier) and paginated apps/pos's own pre-existing fetchRefundsByPayment (it did NOT already use fetchAllPages, contrary to what an earlier draft of this entry claimed — corrected here). One round-3/4 finding assessed and accepted rather than fixed: apps/terminal's useSalesSummary still sums raw total_cents with no refund netting while the now-fixed usePaymentMethodsReport does net refunds, so the two reports can disagree by a refunded amount after a partial/full refund — however this exact disagreement already exists between apps/pos's own legacy useSalesSummary and usePaymentMethodsReport (verified: apps/pos's useSalesSummary has the identical no-netting gap), so this diff makes apps/terminal consistent with apps/pos's existing behavior rather than introducing a new cross-app inconsistency; closing it properly (netting refunds into Sales Summary in both apps) is a materially larger, pre-existing architectural gap ticketed as a follow-up rather than folded into this fix. Also accepted: the ~6 onSuccess callbacks across both apps hand-duplicate the same 2-3 report invalidateQueries calls rather than a shared key-prefix/helper, matching this codebase's existing ~30 other flat-key invalidation call sites rather than deviating from convention; and the fetchRefundsByPayment-shaped helper still exists as two independent per-app copies rather than one shared packages/common export (a real, documented duplication, but extracting it is a bigger refactor than this fix's scope).
(1) Changed both bounds checks to COALESCE(array_length(v_item_ids, 1), 0), so an empty v_item_ids correctly compares against 0 instead of NULL. Verified live against local Supabase in a rolled-back transaction: calling create_pos_order_atomic with p_line_items=[] and a discount at item_index=0 now raises 'discount item_index 0 out of range for 0 line item(s)' instead of silently succeeding. (2) Added an .order('id') tiebreaker to apps/terminal's usePaymentMethodsReport main query, matching the pattern already used one function above it. (3) Rewrote apps/pos's usePaymentMethodsReport main query to use fetchAllPages with the same created_at+id ordering, matching the apps/terminal implementation this diff already added.
Alternatives considered
All three findings were independently reproduced/verified rather than accepted from the review text alone: the bounds-check fix was exercised against local Supabase in a rolled-back transaction (both the negative case reaching the RAISE and, implicitly, the existing positive-index test path this same guard already covers from DEC-370 round 5); the two pagination fixes were verified via tsc --noEmit on both apps/pos and apps/terminal (clean) after matching the exact fetchAllPages call shape already proven correct at two other call sites in this diff. This closes out the branch — no outstanding findings from this round.
Reviewed all 7 files for safety (all purely additive: CREATE TABLE/COLUMN IF NOT EXISTS, ALTER COLUMN TYPE bigint widening, ON CONFLICT DO NOTHING backfills — no destructive ops) and applied 5 of them directly to prod via the approved `supabase db query --linked --file` path, each verified live afterward via direct schema queries: add_playtomic_booking_references, add_is_public_to_lesson_packages, academy_package_price_bigint, academy_payments_amount_bigint, academy_pos_bridge_amount_bigint. The remaining 2 (academy_subscription_model, subscription_rpcs — the academy-subscriptions feature) were NOT applied: reviewing them surfaced that academy_subscription_model.sql itself has never run successfully anywhere, including local dev, because its own backfill INSERT selects player_package_sessions.created_at/updated_at, columns that table doesn't have — every attempt to run this file errors at that exact statement, which is also why its compute_subscription_status() function and status-sync trigger were never created either. A new migration (20260910040655_fix_player_subscriptions_missing_columns, written and verified locally: applies cleanly, create_subscription_from_package/renew_subscription/cancel_subscription now execute successfully end-to-end in a rolled-back local transaction) adds the 3 columns subscription_rpcs.sql needs that academy_subscription_model.sql's own CREATE TABLE never defined (package_snapshot, auto_renew, parent_subscription_id) and completes the backfill/function/trigger with now() substituted for the missing source timestamps. It is committed but deliberately NOT applied to prod yet: local testing surfaced one more bug in the process — compute_subscription_status() can legitimately return 'pending' (a subscription whose starts_at is in the future), but player_subscriptions_status_check's CHECK constraint doesn't include 'pending' as a valid value, so creating or renewing any subscription with a future start date would crash. Confirmed with Alba this feature is not yet in real use (low urgency), so it's deferred as its own dedicated audit rather than folding an open-ended fix into this incident response.
Alternatives considered
Every 'is this migration applied' claim was verified against actual prod schema (information_schema.columns / pg_proc.prosrc content matching, not just object existence — e.g. a function's mere presence didn't prove it was the latest version, since several function names are redefined across multiple migrations in this window; content/marker checks were used to disambiguate). The subscriptions-RPC fix was verified by actually calling create_subscription_from_package/renew_subscription/cancel_subscription against local Supabase in a rolled-back transaction with real inserted test rows, not just by reading the SQL. This incident is the direct trigger for a new standing CLAUDE.md rule (Engineering Mindset: 'Stop-and-report when the foundation turns out broken') and a corresponding personal-memory entry — added mid-session at Alba's request, applied to this same investigation by stopping and checkpointing before touching the subscriptions bugs further.
New migration (20260915070424_fix_next_invoice_number_column_mismatch) drops the dead 2-arg overload and replaces the 1-arg overload's body to use the table's real columns/constraint, keeping the exact same signature and 'INV-YYYY-NNNNNN' return format so no app-code schema change is needed. Concurrency safety comes from the UPDATE ... RETURNING itself (its row lock serializes concurrent callers for the same facility+year) — no separate SELECT ... FOR UPDATE needed. Added the full privilege-revoke block per repo convention: REVOKE ALL FROM PUBLIC/anon/authenticated, then GRANT EXECUTE TO authenticated (the real caller — both apps call this from the browser client, i.e. a logged-in staff user, never anon) and service_role. Also fixed the caller-side bug in both apps/pos and apps/terminal's use-order-mutations.ts: the RPC call now destructures `error` and throws on it, so a future regression here fails checkout loudly instead of silently shipping a sale with no tax invoice number. Verified locally: (a) reproduced the original column-does-not-exist error against local Supabase before writing the fix; (b) after the fix, sequential single calls return INV-2026-000001, 000002, 000003; (c) 25 truly concurrent backgrounded psql processes (`&` + `wait`, not psql's \parallel — this environment's psql rejected \parallel as an unknown command) calling the RPC for the same facility produced 25 distinct, gapless sequential numbers with zero duplicates; (d) has_function_privilege confirms anon=false/authenticated=true; (e) tsc --noEmit clean on both apps (pre-existing unrelated errors on main confirmed via git diff, not introduced by this change).
Alternatives considered
Every claim here was verified directly against local Supabase rather than inferred from reading the SQL: the original error was reproduced with a real psql call before any fix was written; the fix was verified with real sequential and real concurrent (backgrounded, not \parallel) RPC calls; grants were verified with has_function_privilege, not just read back from the migration text. Confirmed via git branch -a / git worktree list at task start that no other in-flight worktree touches next_invoice_number, pos_invoice_sequences, or tax-invoice numbering.
(1) Restructured create_pos_order_atomic from 'always INSERT, detect replay via ON CONFLICT' to 'check for an existing order by idempotency_key FIRST, mint + INSERT only when none is found.' First attempt at this (mint via a post-INSERT UPDATE, still gated on the same idempotency check) failed in local testing with 'Completed or voided orders are immutable' — pos_orders_prevent_completed_mutation rejects any UPDATE to tax_invoice_number once status='completed', which every order is by the time such an UPDATE would run; caught before merge by actually invoking the function locally rather than trusting the SQL read-through. The check-then-insert restructure avoids that trigger entirely: v_invoice_number is computed and passed straight into the INSERT's VALUES. Gated on p_order->>'tax_invoice_type' IS NOT NULL — the signal both apps' checkout mutations already always send and the payment-link cron (apps/terminal/src/app/api/cron/check-payment-links/route.ts) has never sent — so that cron path's existing 'no invoice number' behavior is preserved exactly rather than silently starting to number a caller this PR never reviewed for that. Both apps' use-order-mutations.ts no longer call next_invoice_number() at all or send tax_invoice_number in the order payload. (2) next_invoice_number now looks up facilities.timezone and computes v_year via EXTRACT(YEAR FROM (now() AT TIME ZONE v_timezone)), matching the established AT TIME ZONE pattern from the coach-availability migrations (e.g. 20260512102224_coach_schedule_flex.sql); RAISEs if a facility has no timezone (NOT NULL column, so only fires for a genuinely invalid facility id — same precedent as that migration). Also revoked next_invoice_number's authenticated grant entirely (round 1 had granted it, since the browser client used to call it directly): now only create_pos_order_atomic (SECURITY DEFINER, owned by postgres) calls it, which needs no grant of its own, so the function's reachable surface shrinks to service_role only. (3) Regenerated next_invoice_number's Args/Returns entry via `supabase gen types typescript --local` and hand-applied just that one entry's diff to all three files — NOT a full regenerate-and-overwrite: the local shared Supabase instance had other concurrent worktree sessions' in-progress schema changes applied (session_value_cents, settled_by, settlement_ref columns from an unrelated pos-settlement-tenders branch), and committing a full regeneration would have pulled those into this branch's diff.
Alternatives considered
All three fixes were verified by directly invoking create_pos_order_atomic against local Supabase (SET LOCAL request.jwt.claim.role = 'service_role' inside a rolled-back transaction), not just by reading the SQL: a same-idempotency-key call pair proved created:true then created:false with the SAME tax_invoice_number and pos_invoice_sequences.last_number incremented by exactly 1 (not 2); two different fresh orders proved sequential numbers (000001, 000002); a third order omitting tax_invoice_type (mirroring the cron's payload shape) proved tax_invoice_number stays NULL, unchanged from before this PR. The immutable-order trigger conflict was caught by this same direct-invocation testing, not by static review, before it could reach merge. tsc --noEmit re-verified clean on both apps after removing the client-side RPC calls.
(1) Comp = pos_order_items.is_foc = true, the flag apps/pos/src/mutations/use-order-mutations.ts already writes for a comp'd line (real face value stays in total_cents; only amountDueCents excludes it from what's charged) — reused as-is, no new column or table. (2) Comps net on ORDER-date basis everywhere (pos_daily_sales_summary, facility_business_report, apps/pos's useFullReport), deliberately NOT matching v_returns' refund-date basis in facility_business_report: is_foc is set atomically at order creation, same moment as subtotal_cents/discount_total_cents, so there's no 'happened on a later day than the order' case the way a refund has. Wired into: both emailed RPCs (pos_daily_sales_summary had neither Returns nor Comps at all; facility_business_report already had Returns via DEC-341/348/357/358/361/364 but was missing Comps), the in-app Sales Summary ladder (which reads useFullReport, not either RPC — that hook already computed refund_total correctly but the UI never displayed it), and by necessity its Reconciliation/PDF-export siblings (same ladder math, would otherwise stop footing once net_sales also nets comp_total). Excluded is_foc lines from every item/category breakdown that sits next to a comp-netted total in the same response (pos_daily_sales_summary's day_items CTE, facility_business_report's Sales-by-category/Top-items, useFullReport's top_items) for the same footing reason — is_foc is a plain column already on those rows, unlike a refund's lump-sum-with-no-line-item-mapping (DEC-346), so there's no excuse to leave it in. Left two things explicitly unfixed, documented as known approximations in the code rather than fixed speculatively: a comp'd line that ALSO carries a per-item discount would be double-subtracted from net_sales (not reachable via any shipped cart UI today, grepped both apps/pos and apps/terminal to confirm), and facility_business_report's new v_comps scan is a 4th pos_order_items pass in a function with a documented timeout history (20260901052553) — flagged to Phase 9 rather than restructured here.
Alternatives considered
Verified both RPCs directly against local Supabase in rolled-back transactions (a real 4-refund facility/day plus a synthetic is_foc line inserted and rolled back): pos_daily_sales_summary and facility_business_report returned IDENTICAL returns/comps/net_sales/total_collected figures for the same facility/date, confirming cross-surface consistency. tsc --noEmit clean on apps/pos after the useFullReport/UI changes; eslint clean (one pre-existing, unrelated warning in sales-pdf.tsx predating this diff, confirmed via git show HEAD). A /code-review high pass on the first commit caught two real correctness gaps (item/category breakdowns not excluding is_foc, a stale Reconciliation row label) plus the two accepted approximations logged above as alternatives-not-taken — all addressed or explicitly documented in a second commit before merge.
Two migrations. Part 1 (20260915113012): new SECURITY DEFINER end_drawer_session_atomic(p_session_id, p_actor_id, p_actual_amount_cents, p_notes) replaces the four-step client sequence — locks the session row (FOR UPDATE) FIRST, then recomputes expected cash from the ledger and writes the close in the same transaction, so it serializes against void_pos_order/refund_pos_order_payment (which already take the same lock before writing their reversal rows). drawerExpectedCents (apps/terminal/src/lib/drawer.ts) ported verbatim into SQL rather than reimplemented. facility_id is derived from the locked session row, not passed in, removing a possible client/session facility_id mismatch. Typed errors (session_not_found, already_closed) follow the existing bare-RAISE-EXCEPTION-matched-by-message convention (edit_payment_method, 20260731093943). Part 2 (20260915115106), applied after Part 1 was verified working under concurrency: added pos_drawer_sessions.z_number (sequential per facility, assigned only at close) with a partial unique index on (facility_id, z_number) WHERE z_number IS NOT NULL — same DB-enforced-invariant pattern as the one-open-session-per-facility index (20260901040059); BEFORE UPDATE/DELETE triggers on pos_drawer_sessions reject any change to an already-CLOSED row (gated on OLD.status = 'closed', so the RPC's own open->closed transition is never blocked) except reassigning opened_by/closed_by; BEFORE UPDATE/DELETE triggers on pos_drawer_transactions reject all edits unconditionally except reassigning actor_id, and reject deletes of any row belonging to an already-CLOSED session (a still-open session's rows can be deleted — nothing about them is part of a finalized Z-numbered record yet) — the actor_id/order_id/session_id UPDATE exceptions mirror pos_orders_prevent_completed_mutation's narrow pos_customer_id-attach exception (20260803084501), not a blanket carve-out; the ledger DELETE trigger's session-status lookup takes FOR UPDATE (same lock end_drawer_session_atomic itself takes) so it can't race a concurrent close. REVOKE INSERT/UPDATE/DELETE (SELECT left alone — already RLS-scoped, and both apps read these tables directly client-side) on both tables from anon/authenticated, since every real write path already goes through a service-role server action or a SECURITY DEFINER function (which runs as its owner, postgres, unaffected by the revoke). A CHECK constraint (pos_drawer_sessions_closed_requires_z_number) enforces status <> 'closed' OR z_number IS NOT NULL — added after backfilling every pre-existing closed session's z_number (sequential per facility, ordered by closed_at), the same backfill-then-enforce pattern as 20260901040059 — so a raw UPDATE/INSERT bypassing the RPC can never produce a permanently-immutable closed session with no z_number. The 4 e2e sites (apps/terminal/e2e/drawer-integrity.spec.ts, apps/pos/e2e/void-order.spec.ts) that used to close sessions via a raw UPDATE were switched to close via the RPC instead, so they satisfy the new constraint.
Alternatives considered
Verified atomicity with a real concurrent-transaction race against local Supabase, not by reading the SQL: a background transaction locked a live test session (FOR UPDATE), slept 6s, then inserted a pay_out row simulating a void reversal before committing; end_drawer_session_atomic invoked mid-hold blocked for the full window (started 11:49:51.23, finished 11:49:55.29 — ~30ms after the void's 11:49:55.26 commit) and returned expected_cents=9500 (10000 opening float minus the 500 pay_out that committed WHILE the close was blocked waiting) — the exact race the old unlocked read would have lost. Re-invoking the RPC on the same now-closed session raised already_closed. Trigger immutability verified directly: UPDATE/DELETE against a closed session and against a closed session's ledger row both raised their typed errors; the merge exception (closed_by reassignment to a real auth.users id) succeeded through the same trigger; z_number incremented 1, 2 across two sequential closes on one facility and a hand-forced duplicate z_number insert hit the partial unique index (23505). Grant revocation verified with SET ROLE authenticated: a direct INSERT was denied (permission denied for table), a SELECT still succeeded (RLS-scoped, unaffected). tsc --noEmit clean on apps/terminal and apps/pos. Did not run supabase db reset to verify migration ordering end-to-end, or the full apps/pos e2e suite, per this repo's shared-local-Supabase guidance to prefer direct SQL verification over operations that reset/exercise state for every concurrent worktree session — both migrations were instead applied individually via psql -f against the live local instance with clean output and are additive-only (new function/column/index/triggers, no ALTER/DROP of existing columns), and the one e2e path found to depend on ledger-row deletion was reproduced and re-verified directly at the SQL level instead (below). A /code-review high pass plus a self-caught issue on re-reading the diff found three real problems before merge, all fixed and re-verified: (1) the first database.types.ts regeneration piped the Supabase CLI's stderr banner into the file's first two lines (invalid TypeScript) — corrected to discard stderr, tsc re-confirmed clean; (2) the ledger's append-only UPDATE trigger didn't account for pos_drawer_transactions_order_id_fkey/_session_id_fkey both being ON DELETE SET NULL — Postgres fires the same BEFORE UPDATE trigger for that FK-cascade's internal UPDATE, so deleting a pos_orders or pos_drawer_sessions row with ledger history (exercised by apps/pos/e2e/void-order.spec.ts's cleanup, which deletes the parent order) would have hard-failed with drawer_ledger_immutable instead of completing the cascade — order_id/session_id added to the same narrow-exception pattern as actor_id, re-verified with a live INSERT/DELETE-cascade/UPDATE-still-blocked sequence (order_id correctly nulled by the cascade, a direct amount_cents tamper on the same now-order_id-null row still rejected); (3) found on a second read of the same diff (not from the automated review, from re-checking that same e2e file's exact operations line by line): that fixture ALSO directly DELETEs the pos_drawer_transactions rows it seeded, for a session still 'open' at that point (closed only in a later statement) — the original DELETE trigger blocked ALL deletes unconditionally, which would have broken this too. Rescoped to match pos_drawer_sessions' own gating principle — block DELETE only when the row's session is already CLOSED, since an open session's ledger isn't part of any finalized Z-numbered record yet — and re-verified live: a row on an open session deleted successfully, the same row (now on a closed session, and the RPC's own close row) both correctly rejected. A second, independent /code-review pass over the resulting diff found three more issues, all assessed and two fixed before merge: (1) CONFIRMED — nothing required a closed session to actually carry a z_number; fixed with the backfill + CHECK constraint described above, verified by resetting local state to a genuine pre-migration baseline (dropped all four Part-2 triggers, dropped the not-yet-successful constraint, nulled z_number on the 3 real local closed sessions) and re-running the corrected migration fresh end-to-end with zero errors — the backfill (UPDATE 3, sequential 1/2/3 ordered by closed_at) and the CHECK constraint both landed before the triggers that would otherwise have blocked them, matching a genuine first-time prod deploy; then directly confirmed a closed-without-z_number INSERT is rejected and an RPC close still succeeds. (2) CONFIRMED — the ledger DELETE trigger's session-status lookup wasn't locked; fixed with FOR UPDATE (described above), re-verified the open/closed delete-scoping behavior was unchanged after the fix. (3) Assessed as correct-as-shipped, not a bug — pos_drawer_sessions.facility_id/pos_drawer_transactions.facility_id are ON DELETE CASCADE, so a facility hard-delete on a facility with closed drawer history would hit the same immutability triggers and abort; confirmed via grep across every app that the one shipped facility-DELETE call site (apps/admin/src/app/api/admin/facilities/route.ts) only ever fires as a rollback immediately after a facility is created (before it could have any drawer history), so nothing shipped regresses, and failing loudly is the correct behavior for audit data regardless — documented in the migration rather than worked around.
Rebuild, don't rebase: four brand-new migrations that CREATE OR REPLACE each function starting from main's CURRENT body, with #1083's changes re-applied on top. Every fix main shipped in between is preserved and called out in the migration headers — 'partially_refunded' in every refund status filter, `oi.is_foc = false` in every item/category breakdown, v_comps hoisted into net_sales/total_collected/avg_order_cents, and the academy→POS forward-path pause left exactly as #1114 wrote it. Netting is by PRORATION, the way Square's and Lightspeed's category reports do it: an item-level pos_order_discounts row hits its item; the remainder of the order's discount_total_cents (order-level discounts, and every pre-2026-09-04 order that has no discount rows at all) is spread across the order's items by gross share; a refund (lump sum on ONE payment) is spread across its order's items by gross share. Comp'd lines are excluded from both halves, including from the refund proration's denominator — a comp was never charged so it can never be refunded, and leaving it in would under-attribute the refund to the lines that were actually paid for. Each category carries its top 3 items (`items`) and the email renders one table (category row, items indented); `top_items` is still returned, gross, for API compatibility, and the email falls back to rendering it when a stale RPC returns no per-category items. `discounts_detail` groups pos_order_discounts by name+kind with an EFFECTIVE % and a fallback to pos_orders.discount_name for orders discounted before the till wrote detail rows; `returns_detail` lists each refund with ticket (facility_tickets.ticket_number → tax_invoice_number → short id), amount, reason and local time — no actor, pos_refunds has no such column. `looking_ahead_horizons` (daily only, v_days = 1) returns 1/7/14-day on-the-books numbers each with the same pace comparison the single block had. The email hides Tips/Avg Tip Rate/Fees while zero, drops the On court row (RPC still returns on_court), and renders the discounts/returns detail under the financial section's toggle rather than adding an 11th section key. Crucially the detail block shows NO totals: Discounts, Returns and Comps each appear exactly once, in the Gross→Returns→Discounts→Comps→Net ladder #1097 shipped, which is the thing that has to foot — so there is one source per number rather than two that can disagree.
Alternatives considered
Owner-driven, question by question. The rebuild rule was: start from `git show origin/main:<migration>`, apply the intent, and make every preserved fix visible in the header comment so a reviewer can check the rebuild rather than trust it. Verified numerically in rolled-back local transactions (SUM(categories) = financial.net_sales to the satang with a comp line and a partial refund in the window), not eyeballed. Pre-existing divergence left alone and documented: the POS email resolves categories through the CURRENT catalog (variation → item → category) while the business report uses the sale line's snapshot; this PR only adds the snapshot as a FALLBACK in the POS email so product-less lines (see DEC-378) get a bucket, leaving every line that resolves today resolving exactly as before. OPEN QUESTION for the principal, deliberately not decided here: the two emails now legitimately report different refund totals for the same calendar day when a refund lands against an older sale (business report = refund date per DEC-361, POS daily = order date per DEC-375). Unifying them is remediation Phase 9's 'business-day window unification' scope.
(1) Custom Amount requires a root category on the keypad (apps/terminal and apps/pos keypad.tsx, identical files): Add to Cart stays disabled until one is picked, the pick is remembered for the next custom amount in the session, and it travels to pos_order_items.category_id through the existing categoryId → checkout-payload path both apps already use for every catalog line. apps/pos's back-office quick-item lines inherit the open category tab. (2) A new resolver academy_pos_category_id(facility_id) (root category ILIKE 'academy%', shortest name wins; creates 'Academy Programs' once if the club has none), locked to service_role with the FULL three-REVOKE block from #1087 — REVOKE from PUBLIC *and* anon *and* authenticated, then GRANT EXECUTE to service_role. #1083 shipped only the PUBLIC revoke, which is a no-op for anon and authenticated because Supabase's own ALTER DEFAULT PRIVILEGES grants them separately at creation time; as written it would have let an unauthenticated caller with the anon key create categories at any facility id it cared to guess. It also takes a transaction-scoped advisory lock per facility, because pos_categories has no unique index on (facility_id, name) and two concurrent callers would otherwise each create one. (3) record_academy_payment_in_pos_row gets the category on its line INSERT, rebuilt from main's PAUSED body — the #1114 forward-path pause is preserved, so this code is dormant until that path is redesigned, and the resolver's category-creating side effect is currently reachable only from the backfill. (4) The one-shot backfill is REVERSIBLE: it writes the affected pos_order_items ids and their previous category (always NULL, recorded explicitly so the undo is a restore rather than an assumption) into pos_order_items_category_backfill, and every category the resolver had to invent into pos_categories_created_by_backfill — both written inside the same data-modifying CTE as the UPDATE itself, so the audit row and the write can never disagree. The exact UNDO statements are in the migration header. Old custom amounts stay NULL (nothing to infer from). (5) The POS email falls back to the line's category snapshot when the catalog chain yields nothing, so a categorised custom amount reports there too.
Alternatives considered
Root cause first (Architecture-first): the email can only show what the till records, so the fix belongs at the two write paths that create product-less lines, plus one backfill for the gap the July column addition left. Scope measured against production with read-only SELECTs before deciding anything: 1,465 uncategorised lines of 7,810 total (506 in the last 30 days), of which 755 are catalog-resolvable and 22 are academy lines — so 777 get filled and 688 custom-amount lines honestly stay NULL. The report change from DEC-377 (items listed under each category) also makes whatever remains in 'Uncategorized' self-explaining. Nothing was applied to production: every number here comes from a SELECT, and every migration was exercised locally in a rolled-back transaction.
(1) Widen player_subscriptions_status_check to ('active','pending','expiring_soon','expired','exhausted','suspended','cancelled'). This is deliberately a strict SUPERSET of player_packages_status_check ('active','exhausted','expired','cancelled'), so the backfill is incapable of producing an out-of-domain value no matter how production's data moves between now and the deploy. compute_subscription_status additionally passes 'exhausted' through alongside 'cancelled' and 'suspended', because the trigger calls it on every update and would otherwise relabel all 59 backfilled exhausted rows as 'active' the first time anything touched them. (2) Edit 20260904000000 and 20260910040655 IN PLACE rather than superseding them — normally banned, permitted here because both are applied NOWHERE (production's schema_migrations has no entry for either and the tables are absent), and because a fresh `supabase db reset` can only ever pass if the file that aborts is the file that is fixed. (3) Ship Phase 1 with READS ONLY. Every write action on the new screen — cancel, renew, extend, mark paid, assign — is visibly disabled behind one WRITES_DISABLED flag with the reason stated on screen in plain language. Phase 2 moves the writers to player_subscriptions and then flips the flag.
Alternatives considered
Every number here was measured against production with read-only SELECTs before anything was decided: the status spread (206 rows), payment_method and payment_status spreads (all four methods and both statuses already inside the new tables' own CHECKs), zero bad package/user/facility foreign keys, 62 NULL expires_at rows that the backfill's existing COALESCE already handles, and 0 of 210 session rows orphaned. On that data the backfill copies 206 of 206 subscriptions and 210 of 210 session banks with nothing skipped. The whole three-file sequence plus the RLS file was then applied in a single rolled-back local transaction against prod-shaped seed rows covering every status, method and a NULL expires_at. The read-only call is the same instinct as DEC-378's reversible backfill: prefer the version of the change that cannot hurt a customer while it is still being proven.
(1) A trigger mirror, ONE DIRECTION ONLY: AFTER INSERT/UPDATE/DELETE on player_packages and player_package_sessions project onto player_subscriptions and player_subscription_sessions, keyed on the same id (verified read-only on production: 206/206 subscriptions join their package on equal id with 0 orphans, 210/210 session banks match on id AND player_package_id). SECURITY DEFINER with search_path pinned to public, pg_temp; no grant to anon or authenticated. (2) DELIBERATELY NO pg_trigger_depth() guard. check_package_exhaustion updates player_packages from an AFTER UPDATE trigger on player_package_sessions — i.e. at depth 2 — so a depth guard would silently drop exactly the exhaustion transitions this mirror exists to carry. Loop-safety is structural instead: nothing on the subscription side writes back (the only trigger there, update_subscription_status_if_needed, assigns NEW.status on the row already being written), so the write graph is acyclic. (3) The screen's actions now call the EXISTING player-packages routes — PATCH /api/admin/player-packages/[id] for mark-paid, extend, cancel and adjust-sessions, POST /api/admin/player-packages for assign — never raw SQL, and never the player-subscriptions write routes, which are now writers against a projection and are documented as such. (4) RENEW STAYS DISABLED: there is no admin endpoint that 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. (5) expires_at's 90-day fallback for a NULL package expiry is applied at INSERT and preserved on every UPDATE — 62 of production's 206 packages have no expiry, and re-deriving now() + 90 days on each write would walk a member's access window forward every time anything touched the package. (6) CLOSE THE AUTHORISATION GAP IN THE SAME PR. Both player-packages routes authorised with a bare requireFacilityAccess(req, facilityId) and run on createAdminClient(), which bypasses RLS — 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 those endpoints were only reachable from Academy > Setup; Phase 2a puts four of them on the Members screen, making it an everyday path. The WRITE paths of both routes now narrow to owner/admin/manager via a new player-packages/_lib/access.ts, matching player-subscriptions/_lib/access.ts. READS stay open to every facility role, because a coach legitimately reads packages — the lesson form loads a player's active packages to attach one to a lesson. POST additionally now authorises BEFORE findOrCreateUserByEmail, which previously let a rejected request still create a real auth user as a side effect.
Alternatives considered
Everything was measured before it was decided, read-only against production: the id-keying assumption (206/206, 210/210), the status spread, 62 NULL expiries, all 206 packages on billing_mode 'manual', and — the headline number — 0 of 206 subscriptions and 0 of 210 session banks differ from what the mirror would produce, so the one-shot reconciliation in the migration repairs nothing today and only touches rows that actually drift (it does not churn updated_at across the estate). Verified locally in rolled-back transactions: insert/update/delete mirroring, session mirroring, the depth-2 exhaustion cascade, an exhausted package staying exhausted through a later mirror update, RLS still blocking anon on both tables, reconciliation repairing a genuinely drifted row, and re-running the mirror over every package changing 0 rows. Two things are 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 on production, pinned by a regression test); and the player-packages routes still guard with a bare requireFacilityAccess, so any facility role including coach can reach them, while every money route added since uses WRITE_ROLES (owner/admin/manager). That asymmetry predated this change and was not widened by it — the Subscriptions screen reaches the same endpoints the Player Packages screen always did — but rather than carry a latent gap into an everyday path it is CLOSED in this same PR (see point 6). Every caller was checked before narrowing: no code in apps/pos, apps/terminal, apps/web or apps/calendar_pwa calls these routes at all; the two lesson-form callers are GET-only; and the three e2e specs that touch player packages seed them through the service-role client rather than the HTTP routes, so none of them relies on a coach or cashier writing through them. The allow-list is pinned by a node:test unit suite over all seven roles plus cashier, and a rolled-back local check confirms the role lookup admits exactly owner/admin/manager while all seven still read.
(a) DELETE the standing banner and fix the spacing at its root. The banner was permanent furniture — every facility has payment_provider NULL — and named a setting it did not link to, so it could be read but never acted on. The same fact survives where it is actionable: the Copy-payment-link toast and the pre-send warning inside the reminder panel, both kept. Removing it then exposed the real bug it had been masking: AcademyPaymentsClient returns a bare fragment, which is the house pattern (academy-page-client and academy-setup-page-client do the same) and works because those sit directly under their page's `flex flex-col gap-6` shell. Payments had that too as its own page; it lost it when it became a tab, because TabsContent is a plain block. The payments TabsContent now carries `flex flex-col gap-6` — the same token as the page shells, one container for the whole tab. (b) MEMBERSHIPS ARE RECURRENT PLANS; LESSON SEATS ARE NEITHER. A membership is recurring by nature and cadenceLabel() already prints 'Subscription' against it, so 'Recurrent Plans' stays honest with 8 production payments inside it. A single lesson seat is not a bank of sessions, so it is claimed by neither named option and is reachable under the default All products — visible, never hidden. A package whose template cannot be resolved is treated as unknown for the same reason, via a deliberate undefined-vs-null distinction in packageProduct(). (c) THE TILES DO NOT FOLLOW THE FILTER, AND THE UI SAYS SO — a line under the tiles appears whenever a product filter is active, naming both scopes. The filter itself is SERVER-SIDE, threaded exactly like method and channel: a `product` param the route applies after assembly but before the page slice.
Alternatives considered
The plan/pack split is measured, not assumed: read-only against production, lesson_packages splits 3 with a billing_period to 40 without, package payments 20 to 23, memberships 8, and 0 package payments have no player_packages row behind them — so the unresolved-template branch is defensive rather than load-bearing today. The filter was then proved server-side against the shared local database in a rolled-back transaction over fixtures covering all four feed sources, checking the conservation property that matters: All equals Plans plus Packs plus the rows that are neither, with nothing lost and nothing double-counted, and that the counts hold across a page boundary rather than only on page one. The undefined-vs-null rule inside packageProduct() is the same instinct as the tiles note — when the system has not actually established a fact, it must not assert one, whether that fact is 'this is a one-off' or 'these totals match this list'. The banner is deleted rather than relocated because the two contextual surfaces that carry the same information already fire at the moment the operator can act on it, which is the only moment a warning about a missing payment provider is worth anything.
(a) MOVE THE ROUTE, KEEP BOTH OLD ADDRESSES ALIVE FOREVER. The page moves to /{facility}/academy/members, closing the gap v0.52.0 opened when it renamed the sidebar entry to Members but deliberately left the route at /academy/subscriptions. A redirect stands at the old path, and /academy/payments is RETARGETED at /academy/members directly instead of chaining through the intermediate shim. Both carry the query string verbatim, repeated keys included, through one shared helper rather than the same loop pasted twice — two redirects meant to behave identically must not be able to drift apart. (b) SERVER-SIDE, IN THE DATABASE, on player_subscriptions.package_id → lesson_packages.billing_period: NOT NULL is a Recurrent Plan, NULL is a Credit Pack. The path was VERIFIED before being relied on rather than inferred from the name — player_subscriptions was copied from player_packages and both carry a package_id, so the wrong one would have looked entirely plausible. (c) IT STAYS VISIBLE UNDER ALL. Only 'plan' and 'pack' make the template embed `!inner`; 'all' keeps an outer join, so a row whose template cannot be resolved is listed there and claimed by neither named segment. (d) YES — the count reads the route's own count:'exact', taken after the filter, so the number and the rows describe the same set. That is the opposite of the Payments tiles, which cannot follow the filter (DEC-381) and say so on screen; the difference is that here it costs nothing, so there is nothing to disclose.
Alternatives considered
The classification path and the census are measured, read-only against production: 206 enrolments split 67 plans / 139 packs / 0 unresolvable, and unresolvable is 0 BY CONSTRUCTION rather than by luck — package_id is NOT NULL behind an ON DELETE RESTRICT foreign key to lesson_packages. That is precisely why the filter is still built to keep such a row visible: 'the schema forbids it' is a claim about today's constraints, not about every future row, and the cost of honouring the case is one join modifier. The semantics were then proved against the shared local database in a rolled-back transaction, which included DROPPING the package_id foreign key inside that transaction to manufacture an unresolvable row that cannot otherwise exist — All showed 6, Plans 3, Packs 2, unresolvable 1, the conservation check All = Plans + Packs + unresolvable returned true, and the rollback left 0 rows with the key intact. The `!inner` modifier is load-bearing twice and is pinned by a unit test for that reason: without it PostgREST returns every parent row and merely empties the embed, so both segments would show everything, and it is also what makes count:'exact' count the joined set so the new count agrees with the rows beneath it. Two pre-existing faults in the Enrollments list were fixed rather than built on top of, because the change made both reachable: the fetch swallowed every failure with a bare `return`, leaving a stale list on screen as the apparent answer to a filter that had just been picked — which the route's new 400 would have hit — and the list has always shown one page of a paged endpoint with no pager and nothing on screen saying so.
Come back now — the owner reversed the pause deliberately. An academy payment registering itself in the POS is not a convenience, it is the entire reason DEC-328 put the mirror in a trigger next to the money-truth row rather than in an app call a fifth call site could skip; leaving it off means academy takings are absent from POS reports, the cash drawer and Z-reads until an unscheduled redesign lands. The two faults are separated and answered on their own terms. (a) THE DUPLICATE IS A PROCESS FAULT, and is fixed by process: staff stop keying academy packages into the till. This is not a dodge — the bridge was never creating two orders for one payment (pos_orders.source_id has carried a partial UNIQUE index since the initial schema and the forward path has always keyed on it); the duplicate was a second, independently rung till sale that describes the same real-world sale but shares no key, no customer row and no amount convention with the mirror, so no index, constraint or dedupe rule in the database can see it. The code's own half is nonetheless made structurally impossible rather than merely unlikely: the migration asserts idx_pos_orders_source_id and idx_pos_order_items_source_id IF NOT EXISTS so it owns the invariant instead of inheriting it, and adds an explicit EXISTS short-circuit in front as a fast no-op. (b) THE TAX IS A REAL CODE FAULT and is fixed here, mandatorily, because the accountant's rule is that Thai VAT is due the day the payment is received: an order booked with zero tax on the day money arrived understates that day's output VAT. The rate is not invented and not hard-coded — the bridge now reproduces apps/terminal/src/lib/calculate-taxes.ts (a tax auto-applies when apply_to_all is true; inclusive = base − base/(1+rate/100); exclusive = base × rate/100) against the facility's own active pos_taxes rows, and writes one pos_order_taxes row per tax with order_item_id NULL, the identical shape create_pos_order_atomic writes at the till. The single necessary difference is documented at length in the migration: at the till the LIST price is known and the total derived, whereas here the GROSS is known (p_payment.amount), so the base is solved for — subtotal = gross / (1 + Σ exclusive rates/100) — because pos_payments carries that gross and an order whose total_cents disagreed with its own payment row would break every reconciliation the mirror exists to feed.
Alternatives considered
Every number was measured read-only against production before anything was decided. The tax defect's real size: all 22 orders the mirror has ever produced (business dates 3–16 Sep) carry tax_total_cents = 0. The tax configuration that the fix will actually meet: Pad Thai Padel - Rama IV 7% inclusive apply_to_all, WareHaus Club 7% inclusive apply_to_all, Sunset Padel Club 7% exclusive apply_to_all, Pad Thai Padel Bali 10% exclusive but apply_to_all FALSE — so the fix produces real VAT at three clubs and a truthful zero at the fourth, which is worth stating plainly rather than discovering later as an apparent regression. One genuine surprise, also measured: 20260916111842 (#1114's own file) is NOT in production's schema_migrations at all — the pause reached production only because #1127 rebuilt the function from the paused body — so this migration had to be written to lift the pause without depending on #1114's file ever having run. The behaviour was then proved in a single rolled-back transaction against the shared local database, with main's current state (#1114 then #1127) applied inside that same transaction first so the starting point was real, and the new migration applied twice to prove idempotency: 22 assertions green, including inclusive 7% on a ฿107.00 gross giving 10700/700/10700 and exclusive 7% on the same gross giving 10000/700/10700, a raw duplicate INSERT that bypasses the function being rejected by the unique index, credits and pending still creating nothing, the reversal still producing refund + refunded status + drawer pay_out, and the backfill returning 0. Rolled back, with the local database re-checked afterwards at 0 rows and 180 tables intact.
PAST DEDUCTIONS ONLY, AND 'PAST' COMES FROM lessons.start_time <= now(), NEVER FROM lessons.status. In scope: reason='consume' on a lesson that has already happened, PLUS positive-delta 'refund'/'admin_adjust' rows on those same past lessons, so a wrongly-deducted-then-returned session cannot read as a clean deduction. Out of scope: purchase_grant and backfill_balance, which are grants rather than deductions, and everything in the future. Verified read-only against production 2026-09-17: of 399 consume rows, ZERO sit on a lesson marked 'completed' — 343 are still marked 'scheduled', 223 of which already happened — so a status-based filter would have rendered an EMPTY screen. lesson_id is non-null on all 399, so requiring the lesson to resolve loses nothing. Census: 399 consumes = 249 past + 150 future; in scope 256 rows = 249 consumes + 7 corrections, 81 distinct players, earliest 2026-04-02. The /academy/lessons screen Nestor pointed at was a PRESENTATION reference only — this is not the schedule, and it shows nothing upcoming.
Alternatives considered
Every number above was read from production before the query was written, which is the only reason the status trap was caught rather than shipped — 'completed lessons' is the obvious reading of 'past' and it produces a screen that is silently, totally empty. The scope is deliberately the narrow one Nestor named, with corrections added because they are the difference between a ledger and a list: the purpose he stated is checking that deductions were made PROPERLY, and a deduction that was reversed is the single most important row on the screen.
lessons.class_id, DIRECTLY, with the type filter expressed as a nested classes!inner embed under lessons!inner. This is sound because the coalesce provably collapses: verified read-only against production, across all 256 in-scope rows BOTH columns are non-null (lessons.class_id is NOT NULL in the schema), so the fallback arm can never fire and the expression equals lessons.class_id — which is the authoritative, template-defining side anyway. The classes embed is INNER only when a named type is filtered on and stays OUTER under 'All', so a lesson whose class template could not be resolved remains visible under All and is claimed by neither segment rather than vanishing from every view. The lessons embed is unconditionally inner: the screen is defined by start_time, and a consume row whose lesson cannot be resolved is not a past session. An unrecognised type value is answered with 400, never widened.
Alternatives considered
The collapse was measured rather than assumed, and the measurement turned up something worth keeping: the two columns DISAGREE on 6 of the 256 in-scope rows — a ledger row naming a different class than the lesson it points at. Those rows are read-only here and are reported rather than reconciled, since rewriting ledger history to make a filter tidier is precisely the wrong direction on an append-only audit table. Following Nestor's rule (the lesson's class is authoritative) resolves all 6 consistently, and the !inner/outer split is the same idiom DEC-382 established for the Enrollments product filter one screen over.
A THREE-WAY, HONEST SPLIT, flagged on the row. 'Not returned' (red) — a consume on a cancelled lesson with NO positive delta anywhere on that package: unambiguous, the club kept the session. 'Check refund' (amber) — no credit came back on THIS lesson, but some later positive delta exists on the package, which may or may not be this session's give-back. No flag — the credit came back on the same lesson, or the lesson was not cancelled. Census INSIDE this screen's own scope (past lessons only), verified read-only: 26 consumes sit on cancelled lessons, 7 had the credit returned, and of the remaining 19, 15 are unambiguous (11 players) and 4 are ambiguous (4 players). Read-only throughout — nothing is written, fixed or reconciled. The give-back lookup is FATAL (500) rather than a degraded column, unlike the name lookups, which fall back to an em-dash.
Alternatives considered
The screen's stated purpose is 'to make sure packages deductions are made properly', so the case where they were NOT is the feature, not a footnote — it is stated plainly ('Not returned') rather than softened. Keeping the ambiguous rows visibly separate is the same instinct as packageProduct()'s undefined-vs-null rule one screen over: when the system has not actually established a fact, it must not assert one in either direction. Re-deriving the census inside the screen's own scope, rather than reusing the wider one already measured, is what stopped a true-sounding number being printed against a table it did not describe.
FILTER IN THE DATABASE, ORDER IN MEMORY. Every filter — facility, scope, past-ness, date range, type, search — is applied server-side by PostgREST BEFORE count:'exact' and before the page slice, so `total` and the visible rows always describe the same set. The filtered set is then read in one bounded window (LEDGER_WINDOW = 1000), ordered by lesson start_time, and sliced. Ordering cannot change WHICH rows are in the set, only their sequence, so the count stays exact regardless. If a facility ever exceeds the window the response returns truncated:true and the screen says so, rather than paging a partial set that looks complete.
Alternatives considered
This is the narrowest version of the academy-payments precedent: that route filters after assembly because its filters are derived values no column holds, whereas here only the SORT is inexpressible. Production's entire in-scope ledger is 256 rows across all six facilities (164 for the largest club), so the 1000-row window is far above any real club's history — but it is a bound with an honest disclosure attached rather than an assumption that the data stays small. The ordering trap this guards against is specific and was called out before the work started: filters applied after the slice make the total and the rows disagree, and nothing on screen reveals it.
Resolve candidates from the LEDGER's own population, not from the customers table, and bound every step. (1) One read of this facility's ledger rows (user_id + lesson_id, `count: 'exact'`, capped at SEARCH_CANDIDATE_WINDOW = 5000) gives both the students who could own a row and the lessons the ledger actually references. (2) This facility's coaches come through academy_facilities → coaches.academy_id. (3) One read of those candidates' names, matched IN MEMORY via a tested `matchesTerm`, replaces the ilike over every customer in the product — which also removes that PostgREST `.or()` injection surface entirely. (4) The coach side is bounded by the LEDGER's lessons rather than the lessons table, read back in chunks of 100. (5) Any bound that is actually reached sets `search_truncated`, which the screen renders as 'these results may be incomplete' — the response says so rather than returning a subset under a confident count. 🔴 AND THE SEARCH WAS NOT MERELY WRONG AT SCALE, IT WAS BROKEN AT EVERY SCALE: the coaches lookup filtered `.eq('facility_id', facility.id)`, and `coaches` HAS NO facility_id column. PostgREST answered 42703, the route's own error branch caught it, and EVERY non-empty search on this screen returned 500 'Could not search coaches'. The reviewer had attributed that 500 to URL length; the real cause is a column that does not exist, which means this feature never worked once.
Alternatives considered
Every bound here is a measured number, read-only against production, rather than a round one that felt safe. The whole ledger is 833 rows across six facilities; the largest club holds 609 of them, 115 distinct students and 197 distinct lessons. That last pair is what shaped the coach side: the busiest facility has 4,281 lessons and its busiest coach 1,211 of them — past PostgREST's 1,000-row ceiling, so 'which lessons did this coach teach' would have silently dropped rows and then inlined ~1,000 uuids into the next URL, reproducing both halves of the original bug on the coach path. The ledger references only 197 lessons at that club and 81 for the busiest coach, so inverting the question — read the ledger's lessons, keep the ones those coaches taught — makes the list an order of magnitude smaller and, chunked, complete rather than merely bounded. The 42703 was proved, not inferred: `select id from public.coaches where facility_id = '…'` against production returns ERROR 42703 column does not exist, which is exactly the filter PostgREST would build. The one deliberate semantic change is that matching is now plain substring rather than SQL LIKE, so `_` is literal — narrower than ilike, the more defensible reading of a name search, and pinned by a unit test.
No — it inverts the screen's purpose, so the read is now `.in('reason', RETURN_REASONS)` with RETURN_REASONS = ['refund', 'admin_adjust']. A positive delta is not evidence of a give-back: `purchase_grant` and `backfill_balance` are grants, which is also why LEDGER_SCOPE_OR already excludes them from the rows themselves. The reason list moved into _lib/ledger.ts as a named constant with a pure `hasLaterReturn()` beside it, and the flag computation calls that rather than comparing timestamps inline — so the invariant lives where a unit test can reach it, which the route's inline literal never could. The same-lesson return check is untouched.
Alternatives considered
Measured against production: positive-delta reasons are purchase_grant, refund and admin_adjust, and four packages already carry more than one purchase_grant — so this was live, not hypothetical, and would have surfaced as a red flag quietly turning amber. The tests were verified to FAIL before being trusted: reintroducing `purchase_grant` into RETURN_REASONS turns three of them red, including one that walks the exact production sequence (lesson cancelled → session kept → package renewed) and asserts the flag stays 'never_returned'. A fourth pins the case that makes the fix non-trivial — a real refund must still be found in a history that also contains renewal grants, so the fix narrows the reasons without hiding a genuine return.
From the server: the response now carries `window` (LEDGER_WINDOW), and the client uses `hasMore = rows.length < Math.min(total, windowSize)`. The number is published rather than duplicated as a constant in the component, because two copies of a read ceiling drift and this bug is what that drift looks like. The client defaults to Infinity until a response arrives, so the first page behaves exactly as before. Nothing about the observer changed.
Alternatives considered
The `truncated` banner already tells the operator the ledger was clipped, so stopping at the window hides nothing that was not already disclosed — which is what makes bounding the honest fix rather than a silent one. Latent was stated plainly and not used as a reason to skip it: 197 distinct lessons at the busiest club today, against a 1,000-row window.
Guard it exactly the way feed.ts's facilityCreatedRange does: fall back to plain UTC bounds, and LOG the fallback so it is a fallback and not a silent default. The club's dates still filter; only the timezone interpretation degrades. The UTC fallback for the end of the range is the NEXT midnight rather than 23:59:59.999, because this route compares with `.lt` while feed.ts compares with `.lte` — copying feed.ts's literal string would have dropped the last millisecond of the selected day.
Alternatives considered
Same reasoning as facilityTodayLocal: UTC is the only defensible fallback, and the rule this satisfies is no-silent-errors rather than never-degrade — the degradation is logged with the facility id and the offending value. The operator-visible behaviour is a filtered ledger instead of a 500.
Port the PRINCIPLE, not the shape, because the shape does not transfer — and stop short of the part that cannot be done safely from here. What ports: the people are resolved from THIS facility's own population (the distinct `lesson_players.user_id` behind this facility's lessons, paged to completeness because the busiest club holds 4,894 such rows against a 1,000-row ceiling) plus this facility's coaches via academy_facilities → coaches.academy_id, and the term is matched in memory over that bounded set. The coaches read was also globally unscoped and is now facility-scoped. What does NOT port: the ledger could bound its id list by the ledger itself (197 lessons at the busiest club); this route cannot, and scoping the people correctly makes the LESSON id list BIGGER because it stops dropping matches — measured at pad-thai-padel, a correct `ma` names 2,894 distinct lessons, a ~107 KB `id.in.(…)` URL, roughly three times the largest list this route already sends. So the route now refuses that case with an actionable 400 ('type a few more letters, or add a date range') instead of returning a subset under an unfiltered `total`. Three letters or more is comfortably inside the envelope: cha 405, nat 123, mar 108, ann 33. The lessons-list client was taught to show the route's message, because it previously discarded it and a bare 'Failed to load lessons' would read as a bug rather than an instruction.
Alternatives considered
Every number here was measured read-only against production before the design was chosen, and the measurement is what killed the obvious approach: porting the ledger's candidate-set fix verbatim would have produced a ~107 KB URL on the most common search on a live screen. The distinction the fix rests on is that 'incomplete' and 'wrong' are not the same failure — the people are now resolved completely and correctly, and the only thing refused is a result set that cannot be expressed as a request, with the one instruction that actually shrinks it. Two caveats are recorded rather than dressed up as findings: PostgREST's max_rows on production is assumed to be config.toml's 1000 and could not be confirmed from SQL (no pgrst.db_max_rows appears in any role's rolconfig, so it is set at the service level); and the ~37 KB URL being accepted is inferred from the live symptom being a WRONG list rather than a failed request, not from a measurement. Neither matters to the truncation half, which is arithmetic: 4,406 matches cannot come back in 1,000 rows.
DROP THE lesson_players SOURCE ENTIRELY — both halves of it — and narrow the remaining three to packages. The feed is now exactly: payments.type='package', player_packages not yet paid, and payment_requests whose subject_type='player_package'. Memberships go too (see DEC-392). Both halves of lesson_players are removed for different reasons: the 5,078 DROP-IN rows (player_package_id IS NULL, ฿2,476,800) are point-of-sale takings and are not package money at all; the 410 PACKAGE-FUNDED rows (player_package_id NOT NULL) are worth ฿0 by construction — a package seat deducts a credit rather than taking money — so they are ATTENDANCE, and attendance belongs to the Sessions tab shipped in v0.52.5, not to a money feed. The drop-in capability is NOT deleted as a product idea: Nestor asked for it back as a Revenue tab ('we can later look at this as revenue tab and show all things that are in POS'), and that tab must be built on POS orders. It is deliberately NOT built here.
Alternatives considered
Measured on production, read-only, before anything was changed: at pad-thai-padel the feed carried 4,885 drop-in rows against 27 package sales. The screen named 'Payments' under 'Members', on a tab about packages, was 99.4% walk-ins — the 27 packages an operator came to find were unreachable by scrolling. This is not a filtering inconvenience, it is the screen being about the wrong thing. The two removals are separated in this entry because they are different mistakes: drop-ins are the wrong KIND of money, package-funded seats are not money at all.
EXCLUDE MEMBERSHIPS, having first checked what it costs. A membership is not a lesson package: it carries no lesson_packages template, no billing_period, and no session balance, and it was only ever filed under the 'Recurrent Plans' product because it recurs. Removing it costs NOTHING REAL — verified read-only on production: of the 8 type='membership' payments in the table, ZERO belong to pad-thai-padel or pad-thai-padel-bali; all 8 sit on five `bug019-smoke-*` smoke-test facilities and carry USD 999.00 each. Neither live club loses a row. The DATABASE vocabulary is untouched: payment_requests_subject_type_check still admits 'membership', memberships keep their own screens, and nothing about how they are stored or billed changes.
Alternatives considered
The instruction was to make this screen about packages. Leaving memberships in would have left a second non-package kind on a screen whose whole point is that it now shows one kind — and the tiles would have had to keep counting them. The judgement was only safe to act on because it was measured first: 'this probably does not matter' and 'zero rows at either live club' are different claims, and only the second one justifies a removal.
NARROW THE TILES IN THE SAME CHANGE, VIA A NEW MIGRATION (20260917183320) that CREATE OR REPLACEs academy_payment_summary. Three predicates move and nothing else does: collected_lessons — the CTE summing lesson_players.paid_amount — is deleted; collected_payments narrows type IN ('package','membership') to type = 'package'; open_requests gains subject_type = 'player_package'. Had the route been narrowed alone, pad-thai-padel would have shown a Collected tile of ฿2,692,800 above a list of 27 package sales totalling ฿216,000 — the tile 92% composed of money no visible row could account for. 🔴 DEPLOY ORDERING IS PART OF THE DECISION: the migration and the app deploy must land in the same window, because either one alone puts the tiles and the rows back into disagreement.
Alternatives considered
This exact failure has hit this screen repeatedly, so the rule applied was that both endpoints answer for the same population or neither moves. Two details were verified rather than assumed before narrowing: revert_academy_payment copies the original row's `type` onto its negative counter-row, so a package reversal is itself type='package' and Collected still nets reversals out under the narrower predicate; and subject_type carries a CHECK constraint admitting exactly three values, confirmed against live production, so filtering to 'player_package' drops precisely the two known non-package kinds rather than guessing at an open vocabulary.
NEITHER. NOT A DEFECT, AND THE TILE WAS NOT WRONG. Investigated read-only; nothing was written. The total is a CROSS-CURRENCY SUM that only exists when the facility filter is dropped. Broken out: pad-thai-padel (THB) 27 payments = ฿216,000; pad-thai-padel-bali (IDR) 11 payments = Rp 115,460,000; the remaining 6 are sandbox and bug019-smoke test facilities. Every single row matches its own lesson_packages template price exactly — e.g. '8 Package Lesson' at Rp 12,200,000 stored as 1,220,000,000, which is correct under DEC-108's minor-unit rule. Zero rows disagree with their facility's currency. The ฿115.7M figure came from adding IDR minor units to THB minor units and labelling the result in baht. academy_payment_summary() never does this — it filters p.facility_id = p_facility_id, and each facility has exactly one currency — so the Collected tile has NOT been over-reporting for this reason. No amounts were touched.
Alternatives considered
Worth recording because the alarm was reasonable and the answer was not: an implausible total is exactly what a unit bug looks like, and the instinct to 'fix' it would have corrupted a correct ledger. It also names the trap for whoever writes the Revenue tab or any cross-club report — the moment a query spans facilities, SUM(amount) is meaningless without grouping by currency, and no amount of formatting at the boundary will rescue it. Two live clubs on different currencies already exist, so this is a present hazard, not a hypothetical one.
NARROW THE UNIONS. FeedSubjectType becomes 'player_package' alone and AcademyPaymentRow['source'] loses 'lesson' and 'membership', in both _lib/feed.ts and the client's academy-payments-types.ts. This is what a filter cannot do: reintroducing a drop-in or membership row now fails to compile instead of quietly reappearing under a heading that promises packages. It earned its place immediately — the narrowed union caught a membership branch still live in the payments assembly loop that reading the diff had missed. The database vocabulary is untouched; this constrains what the SCREEN shows, not what may be stored.
Alternatives considered
The instruction was to leave the removed capability recoverable, and git does that better than a permissive type does — a wide union does not preserve the old behaviour, it only leaves room for it to come back by accident. Narrowing converts the product decision into an invariant the compiler holds, which is the difference between a screen that is package-only today and one that stays package-only.
APPEND ONLY, STARTING AT DEC-391, AND RENUMBER NOTHING. This branch takes DEC-391 through DEC-396 and version v0.52.6, on top of the allocation already committed elsewhere: bridge DEC-383/v0.52.3, Members DEC-384/385/386/v0.52.4, Sessions DEC-387–390/v0.52.5. Two properties of this file are recorded here because they surprise everyone who opens it. First, the header says 'newest first' and it is NOT true: DEC-385/386 sit at line 59 while DEC-387–390 sit at line 6232 — new entries genuinely land at both ends, so position implies nothing about recency and only the id and date are trustworthy. Second, 28 decision ids are DUPLICATED on this branch (the same id used by two unrelated entries), so an id is not a unique key today. Note also that DEC-383 is ABSENT here: it lives on fix/academy-pos-bridge-restore, which despite being described as the bottom of this stack is NOT an ancestor of this branch — it is a sibling branched from the same commit.
Alternatives considered
The collisions happened because three branches each picked 'the next id' from a file whose ordering lies about which id is last. Writing the allocation and the file's two traps down costs six lines and is the only thing that makes the next agent's choice mechanical rather than inferred. The ancestry note is included because the stack was described one way and git reports another, and a rebase performed on the wrong assumption is how the earlier entries were lost.
(1) The checker authenticates as the local `service_role` key, not `anon`. This check's job is exactly one thing — does PostgREST's schema cache resolve this relationship — and `anon` would conflate that with an unrelated RLS/grant denial (a correctly-written embed on a table with no anon SELECT grant would 401/403, a false failure). `service_role` bypasses RLS/grants entirely, so a non-2xx response can only mean a genuine relationship/schema-cache problem; the request is local-only, read-only, `limit=0`, so there's no data-exposure downside. Only PGRST2xx-family codes (PGRST200, PGRST201, PGRST205, etc.) fail the build — other errors surfaced during the scan (2 cases of `42703 column does not exist`) are a different bug class (column-name drift, not embed/relationship resolution) that this phase wasn't scoped to guard and are printed as non-failing 'out of scope' output instead. (2) The 6 newly-discovered pre-existing broken embeds (apps/{pos,terminal}/src/queries/use-bookings.ts, use-customer-groups.ts ×2, use-media-library.ts; apps/admin's customer-timeline route ×2) are NOT fixed in this PR. They're recorded in a new `scripts/verify-select-embeds.baseline.json` (exact fromTable+select match, each with the live call site and a one-line diagnosis) so the checker still merges green today, while every one of them is printed on every single run — loud, not swallowed — and separately flagged to Alba in the PR/report rather than silently fixed. Fixing them here would mean guessing product intent for ambiguous cases (e.g. which of two `customers` FKs `crm_customer_group_members` should embed through) inside what's supposed to be a small, well-scoped, backend/infra-only change with standing self-merge authority — exactly the kind of scope creep CLAUDE.md's 'stop-and-report on a broken foundation' rule exists to prevent.
Alternatives considered
A CI guard that's technically installed but neutered by a key choice that swallows real failures (anon) — or one that can never merge because it's honestly strict about a codebase that wasn't 100% clean on day one (no baseline) — both fail at the actual job: catching the NEXT broken embed before it ships. The baseline makes today's known exceptions visible and inert rather than either hidden or a permanent red X, and keeps this PR's blast radius to what it was authorized for (CI infra, zero production data/DB risk) instead of quietly becoming a 6-file app-code fix PR made by an agent that was never asked to make those product calls.
(1) Fetch via `stripe.paymentIntents.retrieve(paymentIntentId, { expand: ['latest_charge.balance_transaction'] })` — one round trip reaches the Charge and its balance_transaction together. Extract fee → processing_fee_cents (verified: THB is 2-decimal per packages/common/src/currency.ts — not in ZERO_DECIMAL_CURRENCIES or THREE_DECIMAL_CURRENCIES — so balance_transaction.fee is already in satang, the same subunit convention as every other `_cents` column; no conversion needed), payment_method_details.card.{brand,last4} → card_brand/card_last4, and balance_transaction.created (charge.created as fallback) → settled_at. Added one extra guard beyond the brief: if balance_transaction.currency !== charge.currency (possible on a merchant account that settles in a different currency than it charges in — not Pad Thai Padel's single-currency THB account today, but a real Stripe edge case), skip persisting rather than silently write a fee expressed in the wrong subunit. (2) NOT built as a blocking wait or a retry loop: the fetch is attempted once, inline, strictly AFTER create_pos_order_atomic has already succeeded; if latest_charge/balance_transaction isn't expandable yet, the columns are left null and a warning is logged. checkout.session.completed fires after payment confirmation (unlike charge.succeeded, which Stripe's own docs note can race ahead of balance_transaction computation), so this is expected to be rare in practice — but a reconciliation cron to backfill any still-null rows is flagged as a RECOMMENDED FOLLOW-UP, not built in v1, to keep this phase's blast radius contained (per the brief and CLAUDE.md's stop-and-report bias). (3) The entire enrichment — including its own pre-check read — lives in one try/catch per call site, runs only after the RPC call and ticket-close logic that matters for correctness, and every failure path (Stripe API error, missing balance_transaction, currency mismatch, Supabase UPDATE error) logs and returns/continues; nothing here can throw back into the webhook's response path or the cron's per-ticket loop. (4) Gated at the fetch, not just the write: both copies of `enrichStripePaymentFee` SELECT processing_fee_cents first and return immediately (zero Stripe API calls) if it's already POSITIVE (> 0) on that source_id+method='stripe' row — cheap enough to run unconditionally on every redelivery/overlap/cron-tick without materially adding Stripe API load. Note the gate is `> 0`, not `!== null`: real local e2e testing (a live test-mode Stripe Checkout run end-to-end against the cron) surfaced that create_pos_order_atomic's own INSERT already writes `COALESCE((v_pay->>'processing_fee_cents')::integer, 0)` — every stripe-method payment row is seeded at exactly 0, never NULL, the instant it's created, since p_payments never sends that key. A `!== null` gate would have silently no-op'd on every single payment, which is exactly the bug this test caught before merge.
Alternatives considered
This is explicitly an accounting-integrity fix, not a checkout/charging behavior change — production is live with real Stripe charges, so every design choice here erred toward 'never risk the payment path to get a nice-to-have number.' Keeping the enrichment strictly after-the-fact, best-effort, and idempotently gated keeps the blast radius to exactly the four new/updated columns on an existing row, with zero change to when or how a pos_payments row is created or what a customer's checkout experience looks like.
I2: restructured create_pos_order_atomic's two sequential INSERTs (pos_order_items, then pos_order_item_modifiers) into ONE statement — a data-modifying WITH, with the modifiers INSERT's SELECT reading explicitly FROM the items INSERT's RETURNING (not just re-deriving item ids from the array both statements already shared) — so the statement-level trigger fires only after both have run. Verified empirically against local Postgres before writing the real fix (isolated parent/child temp-table repro: two sequential statements → trigger sees 0 child rows every time; combined via WITH with an explicit data dependency → trigger sees all of them), not asserted from documentation alone, since an unreferenced/independent CTE's execution order relative to its siblings is explicitly unspecified by Postgres itself. route_order_items_to_stations() and send_ticket_items_to_kitchen() both now emit `modifiers: [{"modifierName": "..."}]` per item — always an array, `[]` when there are none, never an omitted key or null — and escpos.ts renders each with a 7-char ` - ` hanging-indent prefix between the item name and any note. I4: built the full claim/lease migration (claimed_at/claimed_by columns, widened status CHECK, claim_pending_print_jobs RPC using FOR UPDATE SKIP LOCKED, service_role-only) exactly as specified, but made relay_id OPTIONAL (not required) on both /pending's query param and /ack's body — a request with no relay_id falls through to the byte-identical pre-I4 code path (plain unclaimed SELECT; ack's old status==='pending' guard), forever, not just during a transition window. This means the I4 migration + API route ship safely standalone; only the relay binary update (which always sends relay_id, persisted once via a new relay-id.ts, generated locally and never part of the admin-issued setup code) needs on-site coordination, on its own timeline, whenever that becomes possible.
Alternatives considered
CLAUDE.md's 'architecture-first, not patches' bias means the root cause (statement-ordering vs. the trigger) gets fixed, not just papered over with a join that happens to compile; the local-Postgres repro before writing the real fix follows the same bias toward verifying an actual mechanism rather than trusting a plausible-sounding explanation for a food-safety bug. For I4, 'no debt, no compromise' cuts the other way from what it might suggest at first glance: the migration's own design (a job needs to know who claimed it) makes the literal spec's API requirement genuinely backward-incompatible, not just cautious-by-convention — building the optional/legacy-fallback path is the actual clean solution (ships the correct schema and API immediately, isolates the only genuinely disruptive change to relay-binary rollout, on its own timeline), not a shortcut around the design.
The setter list is 6 SQL functions (create_pos_order_atomic, edit_payment_method, refund_pos_order_payment, void_pos_order, unvoid_pos_order, record_academy_payment_in_pos_row — backfill_academy_payments_into_pos calls the last directly and needs no change), verified by scanning the live function bodies of a fully-migrated database, PLUS the service-role callers that are not SQL (pos-stripe-webhook and check-payment-links: enrichment UPDATE; the e2e fixtures: raw INSERT/DELETE). The rules, first match wins, identical across the three trigger functions: (a) service_role passes — with exactly one narrowing: on pos_payments UPDATE it passes only when the statement changes nothing except the four Stripe-enrichment columns (any other service-role UPDATE of a payment is judged like everyone else's). Stated precisely, service_role CAN: INSERT and DELETE on all seven tables (including pos_payments and pos_refunds, for the fixtures); UPDATE the five order-child tables and pos_refunds without restriction; and UPDATE the four enrichment columns of any payment. service_role CANNOT directly UPDATE any other pos_payments column (amount, status, method, tip) of a completed or voided order outside a trusted setter. That limit only stops a mistaken or naive UPDATE; it is not a defence against a holder of the key, who can DELETE a payment and INSERT a replacement — K1 does not defend the money tables against the service-role key; (b) INSERT requires the flag; (c) the flag is the ONE sanctioned escape hatch on UPDATE/DELETE too, for the trusted setters and for backfill/UNDO migrations (`SET LOCAL setpoint.trusted_order_write = 'true'`), on all three functions — except that pos_payments still limits a flagged UPDATE to two field-isolated shapes (refund progress: status to 'partially_refunded'/'refunded' with nothing else changed, repeats allowed, never 'refunded' -> 'partially_refunded'; or a method change), because it holds the money truth and a one-off rewrite of anything else there should DISABLE TRIGGER visibly rather than borrow a flag; (d) ON DELETE SET NULL is recognised by its shape — an UPDATE that changes nothing except nulling the table's catalog-reference columns (passed to the trigger as arguments, enumerated from pg_constraint) AND is issued by the FK action itself (pg_trigger_depth() > 1) — so a direct edit of a sold line's category still fails; (e) a DELETE whose parent row has vanished (order, payment, or for redemptions the gift card) is a cascade and passes, and an UPDATE against a vanished parent fails closed; (f) otherwise UPDATE/DELETE on a completed or voided order raises 42501 (pos_refunds: whatever the order status, for any caller that is not service_role — service_role is not restricted there). The trigger functions are SECURITY DEFINER with search_path = '' so the parent lookup ignores the caller's RLS (NULL now means 'really gone'); EXECUTE stays revoked from PUBLIC/anon/authenticated. Every setter now arms the flag immediately before its first guarded write, after all its guards and early returns (record_academy_payment_in_pos_row twice: before the reversal branch's refund INSERT and before the forward path's line-item INSERT). Known consequence, accepted: deleting a gift card that has redemption history now cascades away those ledger rows exactly as before this migration (rule e), and a gift card delete is otherwise unaffected. create_pos_order_atomic is #1143's merged body plus the flag (byte-diffed). void_pos_order and unvoid_pos_order now authorize (owner/admin/manager, same helper and message as the pos_orders trigger) before arming the flag — behaviour-preserving, since service_role, no-JWT and staff callers were already refused by that trigger; record_academy_payment_in_pos_row's already-reversed replay path restores the flag to the value it found. ROLLBACK is dropping the seven triggers (pos_order_child_prevent_completed_mutation_trigger on pos_order_items, pos_order_discounts, pos_order_taxes, pos_order_service_charges and pos_gift_card_redemptions; pos_payments_prevent_completed_mutation_trigger on pos_payments; pos_refunds_prevent_mutation_trigger on pos_refunds) — the exact statements are in the migration header; the setters' extra set_config calls are harmless without them, so that fully restores prior behaviour (tested against a scratch database in a rolled-back transaction). There is no staged or warn-only rollout. Also guarded in the same round (a defect inherited from #1143, present in both 20260918091522 and this migration's create_pos_order_atomic): a cart line's `modifiers` is expanded only when jsonb_typeof is 'array', because COALESCE(x, '[]') guards SQL NULL only and a JSON null/scalar/object aborted Send to Kitchen and checkout with 'cannot extract elements from a scalar'; EVAL 13 covers it. Rule (d) also failed open for pos_gift_card_redemptions, whose trigger has no arguments: TG_ARGV is NULL there, so both sides of the column-diff were NULL (equal) and the rule collapsed to 'any UPDATE at trigger depth > 1'; TG_ARGV is now normalised to an empty array and rule (d) requires at least one catalog-reference column (EVAL 14 issues nested UPDATEs from a probe trigger and pins it). Locked in by supabase/snippets/pos_order_child_immutability_evals.sql (auto-run by tc-000), which was written first and shown failing on the original migration for each defect and includes a structural guard that every function writing these tables sets the flag.
Alternatives considered
This phase was flagged as the highest blast-radius item in the remediation effort (it rewrites trusted-write logic across void/refund/unvoid/academy-mirroring, on 7 production tables of real transaction, payment and refund data). The first draft had the right idea and a wrong premise, and the premise was stated as a verified fact ('nothing legitimate ever mutates an existing row') — it was verified against the six setters' SQL and not against what Postgres and the other service-role callers do to these tables. The corrected design starts from the complete list of things that legitimately touch these rows (setters, FK actions, cascades, Stripe enrichment, fixtures, backfills), gives each an explicit and narrow rule, and keeps everything else refused. The regression suite was written against the reviewer's reproductions before any fix and now runs with every other SQL eval. Residual, known and out of scope: the cascade rule means deleting a completed order removes its children, so the protection of a completed order against deletion is whatever pos_orders itself has — no BEFORE DELETE trigger, only the pos_orders_delete RLS policy (non-completed/voided orders only; verified: a staff JWT's DELETE of a completed order affects 0 rows), i.e. a superuser, migration or service-role session can still delete one, as before this migration; and the trusted flag, once armed by a setter, stays 'true' for the rest of that RPC's own transaction. Deferred, by decision, to a later change: deleting a gift card cascades away its redemption rows and nulls pos_payments.gift_card_id, and deleting a category/tax/service charge/discount nulls the reference on historical lines (both need RESTRICT, soft-delete or snapshot columns); pos_order_item_modifiers has no immutability trigger; service_role can still write the money tables; the two SET-NULL/parent-lookup code paths are duplicated across the child and payments trigger functions. PR left unmerged pending human review.
Edit the applied migration's text in place. The backfill now runs inside one DO block: the 27 (order_id, reason) pairs are declared once in an ON COMMIT DROP temp table used by both the UPDATE and the assertion, and the invariant is "every targeted order that exists in this database ended up voided" (count of voided targets = count of existing targets), with no hard-coded 27. The UPDATE itself (same rows, same status = 'completed' filter, same voided_by/void_reason/voided_at) is unchanged. Trade-off accepted: this invariant cannot detect a typo'd or missing UUID — a target absent from the database is indistinguishable from a database that never had it, so 26 of 27 present passes. Re-run protection: a DO NOT RE-RUN warning at the top of the file plus a runtime guard that raises if pos_orders.voided_at already exists (this file adds that column and no earlier migration does, so it is a no-op on a fresh replay). The file was applied to prod on 2026-09-07 and must never be re-run there: it would CREATE OR REPLACE pos_orders_prevent_completed_mutation() and overwrite the newer definition from 20260907080933_pos_order_void_rpc.sql (role gate, voided_by = auth.uid() check, un-void transition) and every later redefinition. .claude/rules/migrations.md records this file as an explicit exception to its 'git log the file, run it if touched in the just-merged PR' rule.
Alternatives considered
Editing an applied migration is normally banned because re-running one-shot operations corrupts data. It is acceptable here only because production already applied this file once and is never reset (`db reset --linked` and `db push` are banned), so the text change affects only what a fresh environment replays; prod state is untouched. The runtime guard turns 'never re-run on prod' from a comment into a failure, and the exception note in migrations.md stops the standard 'touched in the just-merged PR, so run it' check from sending it to prod. On a fresh seed-only reset none of the targets exist, so 0 voided = 0 existing passes without any special-case skip.
(1) Order. Cron: the main loop only closes and booking-syncs tickets and QUEUES {ticketId, sessionId, paymentIntentId} for each ticket that closed successfully; enrichment runs in a SECOND PASS after the loop has closed every ticket, so no ticket's retrieve/order/close/sync can ever wait behind a Stripe fee lookup. The pass is bounded: 3 concurrent calls, and no new call starts once a 15s total budget is spent — the rest are reported in feeEnrichmentFailures as `skipped: enrichment pass budget exhausted`, so the pass takes at most the budget plus one call's own 6s deadline. Webhook: enrichment runs after the ticket close in the main path and the (unchanged, already close-first) idempotent short-circuit. (2) Bounds. Every Stripe call in the cron and the webhook's enrichment retrieve get two layers: SDK request options (which abort the request but, on Node, apply `timeout` as a socket IDLE timeout, so alone are not a deadline) plus `withDeadline(promise, ms, label)` — Promise.race against a timer cleared on settle, rejecting with `<code>: <label> did not settle within <ms>ms` — as the real wall-clock cap (stripe-node 20.4.1 has no AbortSignal request option; the abandoned request is left to the SDK to tear down). The two kinds of call get different budgets: the best-effort enrichment retrieve gets `{ timeout: 6000, maxNetworkRetries: 0 }` and a 6s deadline (`stripe_timeout`); the per-ticket `checkout.sessions.retrieve`, which decides whether a paid ticket gets an order, retries a transient error ONCE — an explicit `maxNetworkRetries: 1` (2 attempts, per-attempt timeout 6s, so 2 x 6s + backoff) under a 15s deadline, reported as `session_retrieve_timeout`. The SDK default (`maxNetworkRetries` 2 = 3 attempts, ~19-21s with backoff) is deliberately not used: the 15s deadline would cut the third attempt off and the abandoned SDK request would keep retrying in the background. A failed or timed-out session retrieve was already caught per ticket and recorded in the existing `orderCreationFailures` array (kept: that array is the general 'ticket not fully processed this tick' bucket and its reason string says which step; renaming it would touch every consumer of the response) and the loop continues. (3) Failures. Cron: enrichment returns a failure reason, collected in a separate `feeEnrichmentFailures: { ticketId, reason }[]` in the response plus an aggregate console.error (separate because the order exists and the ticket is closed — only accounting columns were left unset). Webhook: one structured console.error per failure (session id, payment-intent id, pos_payments id when known, reason) AND the reason is added to the response `failures` array as `fee_enrichment(<session>): <reason>` (this file's 'logged and collected' convention; Stripe ignores the body, so the array is visible only to whoever monitors invocations) — status stays 200. Reported failures include: gate SELECT error, Stripe error/timeout, balance_transaction not yet available, currency mismatch (`currency_mismatch: balance_transaction currency <x> != charge currency <y>`; the skip itself is unchanged — nothing is written), and UPDATE error. The webhook's header comment enumerates the only deliberately quiet returns (no payment intent, no matching pos_payments row, already enriched). (4) The gate SELECT destructures `error` in both copies; on error (including maybeSingle's multiple-rows error) it logs and skips explicitly instead of reading it as 'no payment row'. (5) Ticket-close errors. Both copies now check the close update's `error` AND its row count: the update is filtered by `.eq('status','payment_requested')`, so a ticket cancelled/changed/closed in between matches ZERO rows with no error, and both copies use `.select('id')` to tell 'this call closed it' from 'it did not'. Cron: an error is recorded in `orderCreationFailures` as `ticket_close_failed: <msg>`; an empty result as `ticket_close_noop: <ticketId> status changed before close` (can be a benign race with the webhook, which enriches that payment itself); either way NOT counted in `closed` and NOT queued for enrichment (booking sync still runs, unchanged — the Stripe payment happened either way and re-running it is idempotent). An errored close leaves the ticket payment_requested so the next tick retries everything. Webhook: structured console.error with ticket/session/order ids and reason, added to `failures` as `ticket_close(<ticket>): <msg>` (error) or `ticket_close_noop(<ticket>): status changed before close` (main path, empty result), enrichment skipped for that payment, status code unchanged (200). On the idempotent-redelivery short-circuit an empty result is the EXPECTED outcome (the ticket is already closed), so it stays quiet — no failure entry, no error log — and enrichment still runs (the manual-resend recovery path). A 5xx was considered: it would make Stripe redeliver, which would retry the close via the idempotent short-circuit, but it also puts a persistent DB fault on the path to Stripe disabling the whole endpoint — a behavior change to the money path that is not this fix's call; a manual 'resend event' retries the close the same way. (6) ACCEPTED RISK: the webhook awaits enrichment (<= ~6s, always after the ticket close) before returning 200 rather than deferring it. That is safe because the ticket is already closed, and a redelivery arriving meanwhile takes the idempotent short-circuit, which is safe to run concurrently (it writes identical values). EdgeRuntime.waitUntil is deliberately not introduced because its behavior cannot be verified without a Deno runtime; suggested follow-up once it can be.
Alternatives considered
Production is live with real charges, so the fee enrichment must never be able to affect the money-and-state path it piggybacks on: correctness-critical writes (order, ticket close, booking sync) for EVERY ticket go first, the external best-effort work goes last with wall-clock bounds (a per-call deadline and a total pass budget), the one Stripe read that gates correctness keeps its retries, and every failure — including a failed ticket close — is reported through the channel each file already uses for other non-fatal failures rather than console-only. Not decided here (deferred, needs Alba): whether settled_at should be balance_transaction.available_on / the payout date instead of balance_transaction.created — an accounting decision.
(1) Audit trail. ops_checklist_completions never gets an UPDATE to 'un-check' and never a DELETE — checking inserts a new row (completed_by, completed_at, uncompleted_at=NULL); un-checking sets uncompleted_at=now() on the CURRENT open row (the latest one with uncompleted_at IS NULL). 'Currently complete' = latest row by completed_at has uncompleted_at IS NULL. Both directions go through one SECURITY DEFINER function, operations_toggle_checklist_completion() (SELECT ... FOR UPDATE on the existing row, then INSERT or UPDATE), plus a partial unique index (shift_id, item_id) WHERE uncompleted_at IS NULL as a backstop for the no-row-yet race two concurrent first-taps could hit. (2) Phase mapping. The EARLIEST active slot (by sort_order) maps to 'open', the LATEST to 'close'; a single-slot facility gets both phases under it; a 3rd+ slot in between gets no checklist section at all (documented as a known limitation in phasesForSlot's own comment, not silently guessed at). 'Current slot' for the Today page defaults to the slot with the greatest start_time <= now in the facility's own timezone, or the LAST slot if now precedes every slot's start (e.g. 2am before a 07:00 Morning start is the tail of last night's Evening, not a jump ahead to a shift that hasn't opened) — staff can always override via a manual slot selector. (3) Shift creation race. getOrCreateShift() does a plain SELECT; if nothing exists, an upsert with { onConflict: 'facility_id,business_date,slot_type_id', ignoreDuplicates: true }, then always re-SELECTs — whichever of two concurrent first-writers loses the insert (ignored as a duplicate) still gets the winner's row back from the re-select, so both callers proceed with the same shift_id instead of one erroring.
Alternatives considered
P1's stated purpose is replacing a paper/spreadsheet checklist with something staff actually trust — that only holds if a supervisor can see the true history of what was checked, unchecked, and by whom, which rules out any design where an undo is invisible. The phase-mapping heuristic keeps the binary open/close checklist model (P0's own schema, not revisited here) working for any slot configuration without inventing new config fields, at the accepted cost of no checklist for a hypothetical 3rd+ slot — flagged in code rather than solved speculatively for a facility shape P0's own seed data (and the ticket) never asked for. The shift-creation race handling matches this repo's existing 'DB does the atomicity, not app-level check-then-act' bias (same idiom as P0's operations_swap_sort_order()).
(1) Threshold storage: facilities.settings.no_show_badge_threshold, a new key in the EXISTING generic per-facility jsonb settings blob (the same mechanism already holding top_up_validity_months, max_advance_booking_days, max_courts_per_booking, enabled_level_systems) — no new column or table. Read via a shared getNoShowBadgeThreshold(settings) helper (default 2, clamped 1-20), written through the existing PATCH /api/admin/facilities (which already merges into `settings` rather than overwriting it), surfaced as one new small Card on the P0 Setup page. (2) Data source: a new batch RPC, customer_reliability(p_facility_id, p_profile_ids[]), computed over a ROLLING 12-MONTH window (bookings.start_time >= now() - 12 months), not lifetime — deliberately a different number from the CRM page's own existing lifetime stats.noShows/noShowRate (which stay lifetime, just with the denominator bug below fixed). A badge answering 'is this customer CURRENTLY a no-show risk' should reflect recent behavior; a customer with 2 no-shows from 3 years ago and none since shouldn't read as a live risk indefinitely. The RPC is batch-shaped (array in, one row per id out, even for an id with zero bookings) since every call site needs it for a list (search results, a roster) or, for the CRM header, a list of exactly one. (3) Color collision: reused the app's existing amber no-show treatment for the badge (bg-status-no-show/15, text-status-no-show, border-status-no-show/25 — same tokens status-badge.tsx already uses for booking status 'no_show') rather than inventing a new color, but changed the two PRE-EXISTING ad-hoc amber Crown+pill badges that could render immediately next to it — the CRM header's membership pill and booking-players-input's organizer chip — to violet instead. Below-threshold badge state uses a plain neutral outline (no color at all), and a count of 0 renders nothing.
Alternatives considered
Reusing facilities.settings keeps the P0 precedent ('nothing club-specific is hardcoded') intact without growing the schema for a single number. A rolling window makes the badge an honest 'risk today' signal rather than a permanent scarlet letter, and keeping it distinct from the CRM page's own lifetime stats avoids silently changing what that page has always shown (only its rate's denominator bug is fixed, not its counting window). The color fix is deliberately the SMALLEST correct change: the app already has exactly one established color for 'no-show' used in multiple places (status-badge.tsx, this page's own STATUS_COLORS) — matching it is more consistent than a new hue, and the two ad-hoc amber pills it collided with were both bespoke inline Tailwind classes with no other consumers, safe to recolor without touching a shared design token.
Both fixes land inside set_booking_outcome itself rather than in application code, so every caller (admin, terminal, POS, the player self-cancel route) gets them for free. (1) Cascade: a new internal-only helper, _cascade_booking_leg_outcome (EXECUTE revoked from PUBLIC/anon/authenticated/service_role — callable only from within the SECURITY DEFINER function itself), applies the same status flip + its own booking_status_events row to one sibling lesson leg, silently no-op'ing if that leg isn't in the expected from-status (a cascade must not fail the whole outcome because one sibling leg already moved independently). set_booking_outcome now looks up the primary booking's lesson_courts.lesson_id, flips lessons.status, and PERFORMs the helper over every other lesson_courts row for that lesson. Scoped to no_show/confirmed(revert) only — the 'cancelled' path is untouched and keeps using admin's existing cancelLessonCascade, which already owns refund computation; duplicating that here would risk two different refund amounts for the same cancellation. (2) Revert: p_to_status now also accepts 'confirmed', legal ONLY as a transition FROM 'no_show' (never from 'cancelled' — refund/credit implications are deliberately out of scope), and requires a non-empty p_note ('why was this undone'). The revert writes a NEW booking_status_events row (no_show -> confirmed) rather than editing or deleting the original no-show event, keeping the audit trail append-only. Application layer: admin's booking PATCH route drops its ~70-line hand-rolled cascade in favor of one set_booking_outcome call for both no_show and confirmed(revert), closing a second, independently-discovered loophole where a bare {status:'confirmed'} PATCH fell through to a raw generic UPDATE with none of the RPC's guards (silent, audit-less revert). An 'Undo No Show' action was added to both apps: terminal (a new UndoNoShowDialog + useRevertNoShow hook) and admin (a revert-note panel reachable from the booking detail dialog, wired identically from bookings-list.tsx and both calendar views).
Alternatives considered
Moving both the cascade and the revert guard into the RPC is the direct fix for the root cause (drift between callers), not a patch on the symptom (terminal's specific missing cascade) — consistent with this repo's architecture-first bias. The internal-only helper (EXECUTE revoked from every role including service_role) keeps the cascade from becoming a second public entry point that itself needs the same guards set_booking_outcome already enforces. Requiring a note on revert, and appending rather than editing the audit trail, keeps the same accountability standard Operations P2 already set for the original no-show/cancellation reasons.
Hide deactivated rows from the management table BY DEFAULT (not just visually de-emphasize them), with a small 'Show deactivated (N)' text toggle to reveal them again for reactivation — the existing is_active PATCH toggle stays the only underlying mechanism, nothing about the data model or the no-hard-delete convention changes. Applied to both the shared `SimpleLabelListCard` (cancellation reasons, tracker items) and `ShiftSlotTypesSection`'s own separate implementation. The reorder (move up/down) logic was changed to operate on the currently-VISIBLE row list rather than the full row list, so 'move up' always swaps with the nearest visible neighbor — swapping sort_order with a hidden inactive row sitting between two visible ones would look like nothing happened on the first click, then jump two spots on the next. The toggle itself is ephemeral UI state (resets to hidden on reload), not persisted — matching every other filter-state convention already in this app (e.g. the bookings list's own filters).
Alternatives considered
The no-hard-delete convention is already a deliberate, reasoned decision on both tables (referenced rows), so the fix has to work within it rather than relitigate it — the actual gap was that the UI never gave 'deactivate' the visual effect of removal a user expects from a 'remove' action. Hiding by default with an explicit reveal keeps reactivation just as easy as before while making the common case (I don't want to see this anymore) actually true.
(1) counted_by_user_id ('who physically counted this drawer') can be ANY user holding SOME role at the count's facility — validated directly against user_facility_roles, not restricted to an on-shift roster or scheduled-staff table. No roster/scheduling concept exists anywhere in this schema to restrict against, and Pad Thai Padel's actual shift handoff is informal (whoever is physically present counts) — building a roster gate would be speculative machinery for a constraint the club doesn't have. (2) counted_by_user_id is `uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT` (changed from an initial SET NULL during code review). The column is NOT NULL, so ON DELETE SET NULL would have deferred to, then immediately violated, the NOT NULL constraint the moment it fired — a confusing generic 'null value violates not-null constraint' error instead of an intentional one. RESTRICT blocks the user deletion outright with a clear foreign-key error: the audit trail for who counted the drawer must never silently disappear, full stop. (3) Cash is the full session-to-date ledger balance (drawer_session_cash_expected(), unwindowed) on every count, including recounts — never the [window_start, window_end) scoping card/qr/online/other use. Cash is a single continuously-accumulating running balance (every cash_sale/pay_in/pay_out since session open moves it); there is no discrete 'batch' of cash transactions the way a payment gateway settles in periodic windows, so 'cash since the last count' isn't a meaningful reconciliation unit the way 'card payments since the last count' is — the physical drawer only ever holds one true balance to compare against, not a per-window slice of one. (4) 'Recount' (mid-session, routine, does not close the session) and 'close' (terminal, at most one per session) are the SAME write primitive at the SQL layer — record_drawer_count(p_kind) — sharing one insert helper (record_drawer_count_lines()) so the two paths can never produce differently-shaped rows; kind is a column value, not a type-level split. end_drawer_session_atomic calls that same helper internally for its own close-count (Phase 2), and — after a round-4 CRITICAL fix — end_drawer_session_atomic checks FIRST whether a close-count already exists (e.g. pre-recorded via record_drawer_count(p_kind='close') before the till was actually handed off, a flow this primitive design explicitly supports) and reuses it rather than re-attempting an insert that would collide with the one-close-per-session unique index and roll back the entire close transaction.
Alternatives considered
Every one of these four decisions optimizes for what the physical drawer-counting process at a real, currently-live club actually looks like, not a more general model this schema has no other evidence it needs: no roster to gate against, one true cash balance to compare against (not a windowed slice), and a single write primitive that makes 'recount and close can never diverge in shape' true by construction rather than by ongoing discipline. The RESTRICT-not-SET-NULL and idempotent-close fixes both came out of code review specifically because the schema's own NOT NULL constraint and unique index made the naive choice fail in a way that was either confusing (SET NULL) or session-bricking (unconditional insert) — both corrected before this feature reached prod.
A single persistent, device-level "Acting as" selector (not a picker repeated per action) pinned at the top of admin's Operations Today page and in terminal's header. Selection persists in localStorage, keyed by facility + the business_date it was set for; a business-date rollover or the picked person being removed from the roster/facility auto-clears it, forcing a fresh pick rather than silently carrying yesterday's (or a since-removed) person forward. Two shared SECURITY DEFINER validation helpers generalize the two existing per-domain checks instead of each write duplicating its own: operations_assert_acting_user(facility, business_date, user_id) — requires an ACTIVE ops_shift_staff membership on some ops_shifts row for that facility+business_date (admin checklist/tracker/customer-report writes; BLOCKING — these writes refuse to proceed with no acting-as set, inline "pick who you are first" prompt, never a silent session fallback); pos_assert_facility_staff(facility, user_id) — any role in user_facility_roles, no roster restriction (terminal payment verification, both apps' booking-outcome; factored directly out of record_drawer_count_lines's existing inline check so P4c's function calls the new shared helper too instead of duplicating logic). Booking cancel/no-show is the one write NOT gated this way: booking_status_events.staff_user_id is nullable and defaults to acting-as if set, else the session user, validated only when non-null — a booking outcome legitimately has no shift-roster context (a player-initiated or system-driven transition has no staff attributor at all) and Alba on her own laptop should never be forced through a picker to cancel a booking. Every existing session-identity column (completed_by, counted_by, verified_by, reported_by) is kept as-is, additive-only, alongside the new *_user_id real-person column — no backfill; historical rows render "Unknown (shared login)", never fabricated.
Alternatives considered
The device-level chip is the only design that matches this club's actual failure mode — a SHARED device, not a shared PERSON — without multiplying the taps-per-day the shared-login bug already made everyone resent. Reusing (not reinventing) P6's roster and P4c's facility-staff check as two named, shared SQL helpers keeps the validation logic in exactly two places instead of five duplicated inline checks, and the blocking/non-blocking split tracks a real product distinction: shift tasks have no legitimate no-attribution case, booking outcomes do.
A scheduled digest, not an event-triggered one: operations_dispatch_daily_ops_reports() runs hourly via pg_cron and fires send-daily-ops-report for any facility whose ops_report_settings.enabled=true and whose facility-LOCAL wall-clock hour matches its own configured send_hour_local (default 06:00), covering the PREVIOUS business_date (facility_business_date_at-derived, not UTC midnight). Idempotent per (facility, report_date) via ops_daily_reports' UNIQUE constraint — a 'pending' row is INSERTed as a claim BEFORE the edge function is invoked (same claim-then-send shape as P8's sibling dispatch_payment_reminders, 20260915185218), and a stale pending/failed row (>15 min, <3 attempts) is retried on the next hourly tick. operations_compile_daily_report() is the single read-only source of truth for the digest's contents, reusable later for an in-app preview or a different delivery channel without recompiling logic.
Alternatives considered
A schedule is the only trigger that's guaranteed to fire regardless of what this specific club's actual (messy, non-uniform) daily operational rhythm looks like — no close event, no single end-of-day moment, optional shift-close, drawer sessions open for weeks. Matching Square's Close-of-Day-summary and 7shifts' daily-digest pattern by name isn't cargo-culting; both products converged on 'scheduled, not event-triggered' for the identical underlying reason — a retail/hospitality business's operational day doesn't have one reliable machine-readable end.
New migration 20260925074030_print_jobs_lease_reclaim_fix.sql, CREATE OR REPLACE of claim_pending_print_jobs only — no new columns, no new indexes. Reclaim predicate becomes (claimed_by = p_relay_id AND claimed_at < now() - interval '30 seconds') OR claimed_at < now() - interval '5 minutes' (unchanged wait for a different relay). A reclaim (status was already 'claimed', i.e. not a fresh claim from 'pending') now increments attempts and stamps last_attempted_at — reusing the exact column and a local v_max_attempts=3 constant mirroring the ack route's MAX_ATTEMPTS, rather than inventing a second, parallel counter. A pickup that would be the 3rd (attempts already >= v_max_attempts - 1) is moved to dead_letter and released (claimed_at/claimed_by cleared) instead of being claimed again. Restructured as a single UPDATE driven by one 'eligible' CTE with the dead-letter-vs-claim outcome decided per row via CASE, not two separate UPDATEs each re-stating the eligibility predicate — a /code-review finding on the first draft (see alternatives) flagged the duplicated predicate as a future-drift risk. Regression coverage added as supabase/snippets/print_jobs_claim_lease_evals.sql (auto-discovered by tc-000-sql-evals.e2e.ts, this repo's existing convention for pinning an RPC's contract — not a new Playwright browser spec, since this bug is pure DB/RPC with no UI/auth involved); verified RED against the pre-fix RPC, RED against the corrected-away zero-wait draft (isolated exactly to the new R1 assertion, nothing else regressed), and GREEN against the shipped fix.
Alternatives considered
'Schema reuse before new columns' (CLAUDE.md) applied directly: attempts/last_attempted_at/dead_letter already exist on print_jobs for exactly this purpose (the ack route), so extending the existing counter into the reclaim path — rather than adding new columns or a parallel counter — is both the smaller migration and the only design where the two failure modes (explicit ack failure vs. claim-without-ack) can't collectively evade the cap by alternating. The zero-wait-to-30s-grace correction is a direct instance of this repo's own code-review discipline working as intended: the first draft's reasoning about print-relay's poll loop was real but incomplete — it correctly ruled out an in-flight-processing collision but missed that an unhandled exception inside a live relay's own ack path leaves an identical-looking 'claimed by me, not yet acked' state without any crash or restart at all. Verified the corrected fix closes the false-negative (own relay blocked from its own queue for 5 minutes), the false-positive-forever (a job that keeps crashing whichever relay claims it, looping with no record of prior failure), and the newly-found live-relay reprint race, with red/green SQL evals run directly against local Postgres at each stage — pre-fix, the corrected-away zero-wait draft, and the shipped fix.
VISIBLE, NOT OVERDUE. The request is CREATED up to 7 days before its period starts and its due_date is UNCHANGED — still period_start + academy_payment_settings.default_due_days. Implemented as a lead time on the window that already exists rather than a second job: dispatch_package_billing() selects `next_billing_date <= today + 7` and process-package-billing applies the same lead per row via isWithinBillingWindow(). Taking 'overdue' literally would mean dating the request in the past, and feed.ts:isOverdue is `due_date < facilityToday` — so every recurring plan on the screen would read Overdue from the instant it appeared and never stop. A plan that is permanently in arrears by construction tells reception nothing, which is the opposite of the ask. At the shipped default the row is visible for about 14 days (7 of lead, 7 of grace) and turns Overdue only after the cycle date plus grace has genuinely passed in the club's timezone.
Alternatives considered
What the club actually lacks is time to collect, not a more alarming label. Every dominant subscription biller works this way — Stripe raises an invoice ahead of the period end and lets days_until_due carry the deadline, Chargebee ships it as 'advance invoicing' with a days-in-advance setting — because the invoice date and the due date are two different facts about one debt. The 7 is duplicated in SQL and TypeScript because they cannot share a constant across the Postgres/Deno boundary; each names the other in a comment, and the asymmetry is recorded: a SQL window narrower than the TypeScript one silently disables the feature, a wider one merely costs a retried no-op.
IN THE TABLE. A BEFORE INSERT trigger (trg_seed_player_package_billing_cycle) seeds next_billing_date and billing_anchor_day, and copies the template's billing_mode, whenever the lesson_packages template declares a billing_period. player_packages has five live writers — POST /api/admin/player-packages, assign_package_with_credits, the v1 confirm-purchase route and both of the Stripe webhook's insert paths — and fixing one leaves the same bug in four. Three rules the trigger holds: RECURRENCE IS billing_period, never package_type (production's 'Growth' template is package_type 'session_bank' with billing_period 'monthly' and carries 6 of the 14 live recurring enrolments, and billing_period is what the dispatcher joins on); the first seeded date is the first RENEWAL, never today, because the counter sale has already collected the first period; and the cycle is seeded in BOTH modes, because a manual plan needs its renewal date for 'Renewals due' (AC-43) just as much — billing_mode decides WHO raises the request, next_billing_date decides WHEN the period turns over.
Alternatives considered
The bug was never that one route forgot a field; it was that a rule about what a recurring package IS lived nowhere, so every writer had to remember it and none did. Put in the table, it holds for the five writers that exist and the sixth that hasn't been written. It only ever fills blanks — a caller naming its own next_billing_date is obeyed — so it is a default, not a policy that overrides anyone.
NO TO THE PERIOD COLUMNS, YES TO THE RPC. current_period_start/end are reset_package_sessions()'s record of which period has actually been COLLECTED and reset; writing them at enrolment would assert a settlement that never happened and would feed settle_payment_request_subject()'s already-collected guard (`payment_status = 'paid' AND current_period_start IS NOT DISTINCT FROM r.period_start`) a period the biller never raised. They stay owned by the settle path. assign_package_with_credits IS fixed here because this change routes recurring billing through it: it never read lesson_packages, took the charge from its p_price_amount PARAMETER, inserted with the caller's p_facility_id unchecked, and filled session banks 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.
Alternatives considered
Both halves are the same principle — the thing that knows a fact should be the thing that writes it. The period columns belong to whatever actually settled a period; the price and the club belong to the price list, not to whoever called the function. The signature is unchanged so no caller breaks, and the only behavioural difference for an honest caller is that a template edited between the route's read and the RPC call now fails the sale instead of charging a stale number.
NO BACK-DEBT, AND THE BACKFILL IS ITS OWN UNAPPLIED MIGRATION. The first seeded cycle is the first anchor date STRICTLY AFTER today in the facility's timezone, so a package bought on 26 Jul — which has silently skipped 26 Aug and 26 Sep — resumes at 26 Sep 2026 forward and raises nothing for the two missed months. The original billing DAY is preserved (the anchor comes from the sale date, not from the day the migration runs), so students keep the date they were sold. It is split from the feature migration on purpose: 20260917202804 changes no existing row and is safe alone, while 20260917202805 reaches into 14 live student packages, and the two must be approvable separately.
Alternatives considered
Measured read-only on production before writing it: 14 rows, all pad-thai-padel, 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 earliest seeded cycle is 26 Sep and the window reaches 7 days, so on 18 Sep nothing is inside it; the first invoice appears 19 Sep, one student, ฿9,500, and the rest follow one or two at a time. No customer is emailed either: academy_payment_settings has no row for any facility, so reminders fall back to disabled and process-package-billing queues none. The migration prints every row it touches and its own summary, so the apply log is the record of what changed.
THE OLDEST OPEN DEBT, AND THE CURRENT PERIOD IS NOT ASKED ABOUT. pickRequestToSettle() takes the period-less request first (the older, unconditional one-off debt) and otherwise the earliest period_start. The old rule was wrong twice: current_period_start is NULL on all 207 production packages, so the ternary always took its else arm and a cycle request could never be found at all; and advance invoicing makes the match impossible even once that column is populated, because a request raised 7 days early names the NEXT period while the package still reports the current one. SEPARATELY, the silent success is closed: when no request matched and the compare-and-swap matched no row because the package was already 'paid', the route returned a bare 200 that the client toasts as 'Marked as paid'. It now returns a warning naming what happened. 197 of 207 production packages are already 'paid', so that was the common path through the branch, not a corner of it.
Alternatives considered
Both halves were dormant only because the billing fields were never populated and payment_requests has zero rows — this change is the first thing ever to exercise them, which makes it the change that owns them. Getting the lookup wrong is not cosmetic: the request stays open, its reminders keep chasing a customer who has paid, and marking it paid later collects the period a second time — a second payments row, a second POS order and a second drawer entry, because the academy-to-POS bridge mirrors every payments INSERT.
FLAGGED, NOT FIXED — and it is the more serious half of what this investigation turned up. package-form-dialog.tsx submits `billing_period: packageType === 'recurring' ? billingPeriod : null`, so OPENING AND SAVING the Growth template without changing anything NULLS its billing_period and de-recurs 6 live enrolments in one click; the same gate means its billing_mode can never be set to 'automatic' through the UI. Fixing it properly means deciding whether package_type or billing_period is the authority for 'this recurs', which reaches 43 templates, the Payments feed's plan/pack classifier, the dispatcher's join and the subscriptions mirror. That is a data-model call, not a line in a billing PR, and the repo's stop-and-report rule says to checkpoint here rather than chain forward.
Alternatives considered
Recorded now because the backfill makes the consequence concrete: after it runs, 6 students bill monthly on a template whose own form would, if saved, declare that it does not recur at all. The feature is still correct — the dispatcher, the trigger and the feed classifier all key on billing_period, which is the honest signal — but the UI disagrees with them, and an operator is the one who would discover it.
(1) An item is the catalogue VARIATION the line was rung up as (item_variation_id); only product-less lines (Custom Amount, bridged charges) fall back to their name, the only identity they have. The row is labelled with the most recent name it sold under. (2) Category = the line's CURRENT catalogue category (variation → item → category, what the POS pages already used), falling back to the line's category_id snapshot for product-less lines and deleted items; group = the root ancestor via pos_categories.parent_id, the same rule facility_business_report uses. (3) Comp'd (is_foc) lines are excluded from all three screens — one shared hook (useItemSalesLines) so Item sales, Sales breakdown and the drill-down cannot drift again. (4) A bill opens in the OrderDetailModal Transactions already uses, not /orders/[id]. (5) Range, filters and group-by are carried in the query string.
Alternatives considered
The operator's complaint (POS Punch List REP-05) is that the reports could not answer 'when, and on which bills, did we sell X'. That needs a stable item identity first; everything else is presentation. Measured locally: with the root-category groups in place, Item sales and Sales breakdown now return the same gross for the same range (asserted in e2e REP05-04). One more finding, not fixed here: 500 order ids in a single .in() filter (use-reports' CHUNK_SIZE) produce a ~19 KB URL that the local Supabase gateway rejects with 414, which the browser reports as a CORS error; the new hook uses 200. Whether production's gateway has the same limit was not tested from here.
Write the migration to be correct and lossless regardless of what production actually contains, rather than assuming empty: (1) find-or-create a crm_customer_groups row for every pos_customer_groups row, matched by (facility_id, name) — crm_customer_groups' own UNIQUE(facility_id, name) makes this a safe, idempotent match key; (2) backfill pos_customer_group_members → crm_customer_group_members for every membership with a resolvable profile_id, via ON CONFLICT DO NOTHING on the shared (group_id, profile_id) key, and RAISE NOTICE (not silently drop) any row that can't resolve one; (3) detect the one real collision risk — two legacy groups sharing a (facility_id, name) that pos_customer_groups (unlike crm_customer_groups) doesn't prevent, each with their own pos_discount_group_variations row for the same preset — keep the most-recently-updated row, RAISE NOTICE exactly what was dropped and why; (4) only then drop and re-add the FK, pointed at crm_customer_groups. Every lossy branch is a RAISE NOTICE so `supabase db query --linked --file` output on prod is a full audit trail. Verified all three scenarios (find-or-create, membership backfill incl. an unmappable NULL-profile row, and the duplicate-collision drop) locally with synthetic data inserted specifically to exercise paths the real local DB (all-zero-rows) didn't cover, then cleaned up.
Alternatives considered
The task brief was explicit that a straight FK repoint without investigating existing data was not acceptable, and that this worktree cannot verify production's actual state directly. The chosen design doesn't need to know in advance whether prod is empty or populated — it's correct either way, self-reports every consequential decision it makes via RAISE NOTICE, and was proven against synthetic data exercising every branch (empty case already covered by the real local DB being all-zero). The one true edge case that could remain undetected until deploy time is exactly the kind the migration's own safety-gate DO block (RAISE EXCEPTION if any row still fails to resolve to a crm_customer_groups id) is designed to catch before the FK is ever touched.
Fix it, as a second, separately-reviewable migration (20260929054029_crm_customer_groups_staff_read.sql) alongside the FK repoint — additively widen crm_customer_groups_read and crm_customer_group_members_read to also allow 'staff', matching the read access level POS/Terminal operators already have on the equivalent legacy tables via pos_operator_facility_ids(). Write/insert/update/delete policies on both tables are untouched (still manager+ — creating/editing/deleting a group stays a manager-level action; staff only gains the ability to see groups to apply pricing at checkout).
Alternatives considered
The legacy tables' equivalent RLS already grants 'staff' read access for exactly this class of till-operator lookup — crm_customer_groups' policy predates any POS/Terminal consumer and was written for the CRM feature's own original consumers (admin CRM screens, coach/member-facing surfaces) before BUG-082 ever proposed a POS/Terminal reader. Extending it to match is restoring parity with an access level this role set already holds elsewhere for the same kind of data, not a novel grant. 'cashier' remains excluded from both the legacy and crm_ paths — unchanged, not worsened, by either migration in this PR — and is called out as a separate, larger, already-known gap rather than folded into this fix.
Scoped, not removed: loadCartFromTicket now only re-derives isPrePaid/isUnpaid from bookingInfo when bookingType !== 'lesson'. A court booking's own payment_method/payment_status on the bookings row genuinely is the authoritative, current source of truth (it can change after the ticket was created — e.g. an admin later clicks Mark as Paid) and several existing e2e tests (TC-269/270/278) depend on that re-derivation firing for courts. A lesson's bookings row is different in kind: for a group class it's the ONE row shared by every student (via lesson_courts), its payment_method is a meaningless 'pending' placeholder written once at lesson creation (createSingleLesson), and each student's real isPrePaid was already resolved PER-STUDENT at check-in (checkInLessonStudent → resolveLessonSettlement) and persisted into that student's own ticket cart_json. Re-deriving from the shared booking overwrote an already-correct package/stripe/credits isPrePaid=true back to false on every reopen.
Alternatives considered
Confirmed end-to-end with a new regression test (TC-294): reverting the fix reproduces the exact reported symptom (`Charge · ฿800.00` instead of `฿0.00` after reopening a package-settled ticket) and TC-114/TC-270/TC-278b all still pass with the fix in place, confirming court-side re-derivation is untouched. Traced the actual money path too (not just the display): use-order-mutations.ts's amountDueCents (what's actually written to pos_payments) is computed from cart.settlement.valueCents directly, never from the corrupted isPrePaid — so no double-charge ever reached the ledger even pre-fix, but a spurious ฿0 real-tender row was still created alongside the settlement row, and staff saw a live, confusing 'still owes ฿800' prompt for a customer who had already paid. Both are fixed by the same change.
Merge now (this branch), log the two new findings as separate follow-up issues (BUG-112, BUG-113) rather than bundling them in. The round-3 reviewer explicitly traced the item-modal save path end-to-end and found no remaining issue there — the actual reported bug (POS-01/02/03) is confirmed fixed. The two new findings live in unrelated, older UI actions this PR never touched or claimed to fix; they're real but lower-urgency (confusing group/category mismatches, not silent data loss on every save the way the original bug was) and already existed on main before and after this merge either way.
Alternatives considered
Alba explicitly deferred this call ("up to you whatever you think is best"). Shipping the verified-solid core fix and tracking newly-found, out-of-scope, lower-severity bugs as their own tickets keeps this PR's blast radius matched to what it actually claims to do, avoids re-testing an already-thoroughly-reviewed branch a fourth time, and doesn't lose the two new findings — they're fully documented (BUG-112, BUG-113) with root cause and repro for whoever picks them up next.
Root cause is calculate_member_price's cross-window branch (20260731032100). It cut every booking at every start/end boundary of every rule that could apply to the court and priced each piece as ROUND(minutes/60 * the 60-min rate), discarding the fixed 90/120-min prices. That is only correct when the windows really price differently, but it also fired (a) for windows entered separately with identical prices — WareHaus has facility rules 08-11/11-17/17-21 all at 60=1,200/90=1,400/120=1,800, so 120 min at 10:00 cost 2 x 1,200 = 2,400 instead of 1,800 — and (b) for a court-specific rule that fully shadows other windows — courts 1 and 5 have one all-day rule (900 for 120) yet still split at the facility-wide 11:00/17:00 edges, so 120 min at 10:00 cost 1,200. Fix: keep cutting at every candidate boundary, then MERGE adjacent segments whose winning rate card is identical (list + member price for every duration, via new helper pricing_rate_signature); if one segment remains it prices as a single window (exact duration match, DEC-273), otherwise the DEC-183 prorated sum is unchanged. Also (same family, admin app): the Edit Booking dialog re-fetched the NON-MEMBER list price on open and sent it back on Save, so fixing a name silently re-priced the booking; it now re-prices only when court/date/time/length actually change and only sends price_amount when it changed.
Alternatives considered
Reproduced against the real WareHaus rule set, not a synthetic one: before the fix every court showed two prices per duration depending only on start time (e.g. 120 min: 1,800 or 2,400 on courts 2-4; 900 or 1,200 on courts 1/5); after it, every court x duration has exactly one price at every bookable start time. Replaying ALL 60 production pricing rules across every facility (22,095 court/day/time/duration points) through old and new function changed 350 points, all WareHaus, all cheaper, none anywhere else, and no change to the fail-closed price-0 behaviour. TC-295 (8 tests) fails on the old function and passes on the new; its 'unchanged' cases (genuine peak/off-peak proration, fixed non-linear 120-min price, partial shadowing, coverage gap = 0) pass on both. TC-296 (5 browser tests) reproduces the dialog re-price and pins the new contract. Both fixes are needed because the engine defect makes every price wrong at some start times while the dialog defect lets any price, right or wrong, be overwritten on an unrelated edit.
One shared component, components/admin/date-range-picker.tsx, in the Polaris structure: a trigger button that names the preset AND prints the resolved dates at all times ("Month to date · Sep 1 – 28, 2026"), opening a popover listbox of presets with Custom LAST — choosing Custom expands From/To inputs and an Apply button inside the popover, so the two date inputs are never the face of the control and nothing reloads on a half-typed date. Vocabulary, in this order: Today · Yesterday · This week · Last week · Month to date · Last month · Last 7 days · Last 30 days · Custom — the four Nestor named plus their four Polaris companions, drawn in two groups (calendar-anchored, then rolling), each option previewing the dates it would resolve to. Linear finish: h-9 input-height trigger, theme tokens only (light and dark hold with no per-mode class), visible focus rings, cursor-pointer on everything clickable, panel width min(100vw − 2rem, 20rem) so it fits a phone. Keyboard: Tab into the listbox, Up/Down/Home/End roam the options, Enter/Space choose, Escape closes; options are <button role="option" aria-selected>. Applied to Billing, Sessions, Lessons, Bookings and the Operations log; the raw input pairs are deleted, not left behind.
Alternatives considered
Polaris is the named house pattern and Polaris ships exactly one date range picker; converging on its shape is the only answer to "stop doing new and different designs" that does not itself add a design. The vocabulary is the analytics-standard one every operator has met in Shopify, Stripe and Google Analytics, so nothing here needs learning. Printing the resolved dates in the trigger makes the control honest on money screens — "Month to date" does not tell someone reconciling a till which days they are looking at, "Sep 1 – 28, 2026" does — and doing it in the trigger rather than on a second line means it is true on every screen for free.
Members → Billing: Month to date. A money screen's question is "how is this month going"; academy_payment_summary() takes p_from/p_to and the summary route forwards the feed's dates, so COLLECTED is scoped to exactly the rows beneath it and the tile prints the resolved dates. Outstanding, Overdue and Renewals due are deliberately NOT period-scoped — money owed from before the range is still owed, and Renewals is a forward 30-day window by definition — so they carry "· all time" and a line under the tiles states which one follows the period. Members → Sessions: Month to date, pastOnly. A ledger of deductions already taken (the route enforces .lte(lessons.start_time, now)); with the Polaris vocabulary only This week can reach forward, and pastOnly clamps it to Sunday–today so the dates on screen describe the rows on screen. Academy → Lessons: This week, NOT pastOnly. The one screen that looks at what is COMING — "to date" would hide tomorrow's lessons on the screen that schedules them; a Sunday–Saturday week is the coach's working unit and always contains today. Operations → Log: Last 7 days, pastOnly, resolved against the facility's BUSINESS date (the picker's `today` override) rather than its calendar date, because a club whose day rolls over at 04:00 is still on yesterday's business date at 01:00 and this log is keyed by business_date; the Slack digest's ?from=&to= link lands as a Custom range. Bookings: "All time" — the ONE deliberate exception to "no screen opens unbounded". It is a lookup list ("Search by name, email, phone, or confirmation ID") filtering on start_time; any bounded default would make a confirmation-ID search silently miss the row it was typed for and "0 bookings found" would read as a complete answer. The picker offers All time only where a screen opts in (allowAllTime); it prints "All time" on the control so the window is still named, and every preset is one click away. Money and ledger screens cannot reach it. On every screen the period counts as an active filter only when it differs from that screen's default, and Clear filters resets it to the default — never to nothing.
Alternatives considered
A default is a claim about what the screen is for. Money screens answer "this month so far"; ledgers answer "what already happened"; a schedule answers "what is coming"; a lookup answers "where is this row". Each default states that claim, the control prints the dates so the claim is checkable, and the one unbounded default is named on the control rather than hidden. Where a total cannot follow the period, the screen says so in the tile and in a sentence beneath it — stating the mismatch is honest; hiding it is the failure mode.
KEPT, verbatim in behaviour: the pure resolver's design — YYYY-MM-DD strings not instants, `periodToday` with its Intl fallback (renamed rangeToday), pastOnly clamping each end independently, custom ordered-not-trusted with incomplete-falls-back-to-a-preset, the long range formatter, the Billing tiles scope note and its "· all time" tile details, the "period is not a filter unless non-default; clear resets to default" rule, and all 24 tests, ported case for case. CHANGED: the file is lib/date-range.ts (it is not academy-specific any more — Bookings and Operations use it), the vocabulary is the Polaris one (DEC-430), the default is Month to date, and two things the sibling did not need were added: an opt-in All time state for lookup screens and a business-date `today` override for the Operations log. DROPPED: the segmented pill (components/admin/academy-period-filter.tsx), the This month and This year presets, and `useAcademyPeriod` (replaced by `useDateRange`, which returns reset/isDefault so screens stop re-deriving the default). NOT TOUCHED: player-subscriptions (Enrollments) — on main it has no date filter; the sibling adds one together with the route's overlap semantics, so converting it here would duplicate that route work and guarantee a conflict. Rebase recipe for the sibling: drop its lib/academy-period.ts, academy-period.test.ts and academy-period-filter.tsx; in player-subscriptions-list.tsx swap `useAcademyPeriod(timezone)` for `useDateRange(timezone)` and `<AcademyPeriodFilter …>` for `<DateRangePicker … testIdPrefix="player-subscriptions">`; in the three shared files (academy-payments-client, academy-sessions-ledger, lessons-list) take this branch's period hunks and keep the sibling's non-period hunks (settled-row gating, view default, tab pinning). Collections (feature/academy-collections) needs the same one-line swap for its due_from/due_to inputs once it rebases; not edited here because that branch is not this branch's to change.
Alternatives considered
Convergence means keeping what is right and replacing only the presentation. The sibling's resolver IS the right pure half — the criticism was never its arithmetic, it was the pill and the vocabulary. Porting the tests case for case is what lets a reviewer see that nothing in the date arithmetic changed while everything on screen did.
The shipped control, configured through the hook and not forked: useDateRange(timezone, { pastOnly: true }) with <DateRangePicker … pastOnly testIdPrefix="dashboard" />. DEFAULT Month to date (the hook's own default; the house default for money-facing screens, per DEC-431). PAST-ONLY, joining Members → Sessions and the Operations log: this is a performance screen whose revenue is money from sessions that have been played and whose utilization is measured against opening hours that have elapsed, so a period reaching forward would put unplayed bookings in the revenue tile and minutes in the utilization numerator with no elapsed denominator behind them — the exact shape of the 309% bug in DEC-231. NO allowAllTime: every figure here is a sum over a window, so the unbounded state (reserved for the Bookings lookup) would present the club's entire history as a performance summary. WHICH FIGURES FOLLOW THE PERIOD: all of them — the four tiles (Bookings, Revenue, Utilization, Cancellation rate), both charts and the court-utilization table are flow measures over one booking window, so unlike Billing NOTHING here carries a "· all time" label, and inventing one would have been dishonest in the opposite direction. What this screen needed instead was the things that can still mislead said out loud, which the scope note under the tiles now does: (1) Revenue is the value of court and coaching sessions that TOOK PLACE in the period, not cash collected in it, so it will not equal Billing's Collected for the same dates; (2) Utilization is booked court time as a share of opening hours that had ELAPSED; (3) when the period runs up to now, the figures are "so far" and will keep rising (the route returns period.partial and the note says so). The query's upper bound is min(end of date_to, now) precisely so the numerator and the clamped denominator answer the same question. Two further honesty items: an unrecognised date parameter returns 400 with a message naming the parameter (never widened to all time, never silently emptied — a ฿0 dashboard is indistinguishable from a club that took nothing), and the court table, which can only list active courts, now measures and states the bookings/revenue that fell on since-retired courts so its Revenue column cannot silently sum to less than the Revenue tile. Charts bucket by span on the SAME thresholds as the house reporting picker (rangeGranularity: ≤1 day → hour, ≤60 → day, ≤420 → week, else month) so a one-day period is not a single bar and a two-year custom range does not try to draw 730.
Alternatives considered
Inventory first: grepping the admin app found exactly one dashboard with date-bounded numbers. Academy → Overview (academy-page-client.tsx) looks like a second one but its card grid is an academy name plus two entity counts (coaches, linked facilities) and four navigation tiles — nothing on it is date-bounded, and its own comment records that the Reports tile was deleted for deriving figures from current template prices; adding a picker there would have been decoration. GET /api/admin/dashboard/subscriptions-due also exists under a dashboard path but has no caller at all (noted in academy-subscriptions-client.tsx) and was left alone. So the whole change lands on one screen and one route. The discipline that mattered was not adding the control — that is five lines — but deciding per figure whether a period can honestly bound it, and then saying on screen what the dates do and do not cover. Here the honest answer happened to be "all of them", which makes the two definitional caveats and the partial-period notice the real deliverable.
It does, on two live clubs, and the read is now PAGED TO COMPLETENESS with a refusal rather than a partial sum. Measured read-only against production on 2026-10-01, counting the rows the OLD route's welded 30-day window would have requested: Pad Thai Padel 1,821 and Pad Thai Padel Bali 1,008 — both above the 1,000 ceiling, so the Revenue tile an operator reconciles the till against was summing roughly the first 1,000 of 1,821 bookings and printing the result with no indication anything had been dropped. Adding a date picker makes this strictly worse (a two-year custom range is ~20,000 rows), which is why it had to be fixed in the same change rather than filed. The fix follows the house pattern from lessons/route.ts: page in 1,000-row batches up to a 50-page ceiling and, if the ceiling is reached, return 500 with "narrow the period and try again" — because every figure on this screen is a sum, and a sum over an unknown subset is not a smaller number, it is a wrong one. Same class of defect, and the same resolution, as the Collected tile in academy-payments/summary. Two related corrections in the same pass: (1) the paged reads are ordered by the UNIQUE id column, not by start_time / booking_id / lesson_id — paging is only stable over a total order, and four courts booked for the same 19:00 slot is a normal evening at this club, so a non-unique sort key lets a row at a page boundary be served twice (inflating revenue) or skipped (hiding it); (2) lesson revenue is now resolved ONCE for the window instead of twice (once for the 7d tile, again for the 30d chart, from two separate sets of queries) — that duplication is how the headline tile and the chart beneath it came to disagree, and tc-033 now asserts the series sums to the tiles exactly.
Alternatives considered
The date filter did not cause this bug, it exposed it — and it would have made it worse on every widened window. Two clubs' headline revenue figures were already wrong by an unmeasured amount. Paging to completeness and refusing at the ceiling restores the only property that matters on this screen: a number shown is a number over all the rows it claims to cover, or it is not shown at all.
ONE LESSON, ONE CREDIT, ENFORCED IN TWO PLACES. (1) The attribution rule moved out of the route into a pure function, lib/dashboard-range.ts → lessonRevenueByBooking(rows, pricePaxByLesson, playerCounts), which credits the FIRST primary row per lesson (ordered by lesson_id then booking_id so the winner is stable across requests — rows arrive in booking-id chunks, so without an explicit order a lesson's revenue could hop between chart buckets between refreshes) and returns the duplicates and the priceless lessons as diagnostics for the route to log. (2) A migration adds the partial unique index the invariant has always assumed — CREATE UNIQUE INDEX lesson_courts_one_primary_per_lesson ON lesson_courts (lesson_id) WHERE is_primary — WRITTEN AND DELIBERATELY NOT APPLIED. WHY BOTH: origin/main's tile summed once per LESSON row and was structurally immune, while its chart/table path had the per-booking flaw, so a duplicate primary made the tile and the chart visibly DISAGREE — which was the only signal an operator had. Unifying the definition removed that signal, so the guard has to be explicit. Nothing in the database enforced it: the only uniqueness on lesson_courts is (lesson_id, court_id), and migration 20260517072915 asserts single-primary in a comment only. WHICH SEATS COUNT: lesson_players had no status filter, so a player who cancelled out of a lesson that still ran was billed into revenue, even though the lessons read one block up already drops cancelled LESSONS. Seats are now counted where status !== 'cancelled' — filtered in JS rather than as PostgREST .neq() because .neq also drops NULLs and status is nullable (default 'confirmed'), and a NULL-status seat is a real paid seat that .neq would silently delete from revenue. THREE SILENT ZEROS SEPARATED: a cancelled lesson (expected, not logged), a lesson with no price at all (logged — real money vanishing, not a free lesson; pricePaxByLesson is held as number|null rather than coerced with ?? 0, so a genuinely free lesson at price 0 stays distinguishable), and a lesson booking with no lesson_courts row at all (logged — the join row is missing).
Alternatives considered
Measured read-only against production 2026-10-01 before deciding: 5,160 lesson_courts rows, 5,159 flagged primary, ZERO lessons with more than one — and pg_indexes confirms no index mentions is_primary. So the double-count is latent, not live, and the index builds today without violation. The seat filter is likewise a no-op on current data (all 8 cancelled seats sit on lessons that are themselves cancelled, which the lessons filter already removed) — self-neutralising by luck rather than by design, which is exactly the kind of thing that stops being lucky. The deeper lesson is about test coverage: tc-033's seed 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. Nothing in this PR could have caught the defect. The seed now sets is_primary and two players (2 × 80,000 = 160,000), which distinguishes counted-once from counted-twice from priced-per-lesson from never-ran.
Optional discount {type percentage|fixed, value, reason} on POST /api/admin/player-packages. payments.amount stays the NET received (so every collected/revenue sum is unchanged); new payments.discount_amount/discount_type/discount_value/discount_applied_by record the cut, reason in existing payments.notes. record_academy_payment_in_pos_row books the POS order at list price with a pos_order_discounts row (kind cashier_adhoc), total = net. Anyone who can assign may discount; above 50% of list needs an owner (enforced server-side). First payment only; session_value_cents is set from the net. Discounts are refused on credits sales for now.
Alternatives considered
Keeps payments.amount truthful, makes the POS ticket read like a till order with a manual discount, and gives automatic proration an existing home (a system-computed discount) so nothing is redone later. Verified end to end by TC-297 (UI to payments to POS order, items, discount line, payment reconciling exactly) and 18 unit tests. The bridge function is carried over byte-for-byte except the discount lines, and with discount_amount = 0 behaves identically to before.
lesson_packages.package_type becomes the SINGLE DECLARED AUTHORITY, 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). Mapping: session_bank = SESSION PACK (exactly one distinct item key), credit_pack = CREDIT PACK (two or more distinct item keys — a program of different elements), recurring = RECURRENT PLAN (has a cadence). An item key is (coalesce(class_type,'') || ':' || coalesce(class_id::text,'')); two rows sharing a key are still ONE kind, because consume_package_session drains them as one bucket. Item shape classifies the existing 43 templates ONCE, in the backfill; it is not how the system answers the question afterwards.
Alternatives considered
Declared beats derived because the kind drives routing and eligibility, not just a label — so it must be stable against unrelated edits. Binding it to billing_period with a CHECK rather than replacing that column keeps DEC-411 intact: money code still branches on `billing_period IS NOT NULL`, and the coherence constraint is what makes that predicate identical to `package_type = 'recurring'`, so the seed trigger, the billing dispatcher and the Payments feed's cadence fields need no predicate change at all. package_type answers WHAT THIS IS; billing_period answers WHEN THE MONEY MOVES.
NO NEW COLUMN. package_type is widened in place.
Alternatives considered
CLAUDE.md's schema-reuse bias, applied literally: package_type already captures exactly this concept — it was merely too narrow. Widening a CHECK is additive and reversible; a parallel column is neither, and it would guarantee drift rather than merely risk it.
NO. The legacy value names stay; display names are mapped in ONE shared constant, packages/common/src/package-kinds.ts (PACKAGE_KIND_LABELS / PACKAGE_KIND_LABELS_PLURAL). No screen hardcodes a kind label any more.
Alternatives considered
A stored enum value and a product name serve different audiences and are allowed to differ. Churning a live API contract to make them match is cost with no benefit; centralising the labels gets the entire user-visible benefit and also fixes the real defect, which was that five screens each wrote their own label.
OMIT the key, do not null it: `...(packageType === 'recurring' ? { billing_period, billing_mode } : {})`. PATCH's allow-list only writes keys that are PRESENT and POST already defaults both, so saving 'Growth' unchanged now touches neither field. Plus a server guard in lesson-packages/[id]/route.ts: an explicit `billing_period: null` on a template that has a cadence AND has active enrolments returns 409 naming the template, the student count and the cycle. The predicate is a pure function with 8 unit tests. Shipped as its own commit, mergeable and deployable alone, ahead of the migration.
Alternatives considered
The harm was specific and measured: 'Growth' carries 4 ACTIVE enrolments (27 total) and already had 2 DRAFT payment_requests raised for periods starting 2026-10-01 and 2026-10-07, so de-recurring it would ORPHAN INVOICES ALREADY ISSUED, not merely stop future billing. Omission is strictly safer than any value because it makes the destructive write unrepresentable rather than merely conditional. The server guard exists because the form is not the only client that can reach the route. Verified read-only against production 2026-10-01. A third instance of the same bug was found and fixed while here: `valid_days` was gated on `=== 'session_bank'`, so saving either credit pack would have nulled its validity (180 and 90 days on production) the moment the migration declared it a credit_pack; the gate is now `!== 'recurring'`.
PR-1 form fix → PR-2 read-tolerance (shared labels + both v1 gates) → migration (widen CHECK → backfill → coherence CHECK) → PR-3 filters and docs. Read-tolerance MUST precede the migration.
Alternatives considered
An exclusion gate rejects every value it has not been told about, so widening a vocabulary before teaching the readers is a self-inflicted outage. Both gates are now INCLUSIONS on 'recurring' — which is what they always meant (a cadence belongs to /subscribe; a payment link to a cadence re-opens the rollover question DEC-403 deferred) — so they now fail SAFE for any future fourth kind, and they match /subscribe's gate, which was already an inclusion.
ONE definition in packages/common/src/package-kinds.ts: the three kinds, both label maps, the accepted `product` values, the alias resolution and the shown segments. Both screens import it; neither declares a vocabulary. player-subscriptions/_lib/product.ts keeps only what is genuinely local (the PostgREST select string and its hand-declared row shape) and re-exports the rest so existing import paths survive. The predicate moves to package_type: `.in('lesson_packages.package_type', productFilterKinds(product))`. The legacy aliases are KEPT — ?product=plan → recurring, ?product=pack → session_bank OR credit_pack.
Alternatives considered
The two filters did not merely duplicate a rule, they DISAGREED with the rest of the product: reading billing_period put 'Growth' under Recurrent Plans while every package_type-reading screen badged it a session bank. 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 — this is a widening of the former 'pack' segment into two, not a reinterpretation of either. 'pack' deliberately spans TWO kinds, which is what makes it a faithful alias: it meant 'everything that is not a plan' and still does.
FREEZE, DO NOT DROP. Documented as frozen in COMMENT ON COLUMN; no longer written or read by the admin app. The form's 'Billing Cycle (days)' input is removed, the column is gone from the dashboard's subscriptions-due select, and the dialog's own type no longer declares it.
Alternatives considered
It turned out to be not merely unused but UNWRITABLE: absent from POST /api/admin/lesson-packages' insert block and from PATCH's editableFields allow-list, so the form's `billing_cycle_days: parseInt(...) || 90` had been silently dropped on every save since it was written — which is exactly why all 43 rows are NULL. Removing the write is therefore behaviour-preserving, and removing the input is a net honesty gain: a control that claims to set when subscriptions renew and has never changed one row is worse than no control. Every remaining reader wraps it in COALESCE(..., 30 or 90), and because the column is NULL everywhere those reads already take their defaults, so freezing changes no behaviour anywhere. Removing those references, and only then the column, belongs to the recurring-billing work. NOTE: `enrollment_model` and `auto_renew_default` in the same form card are the SAME dead keys — flagged in a code comment and reported, deliberately not fixed here.
NOT VALID, then VALIDATE CONSTRAINT, both in this migration, preceded by an explicit pre-check that RAISEs naming any offending rows. Statement order is widen CHECK → backfill → pre-check → ADD NOT VALID → VALIDATE, in one transaction.
Alternatives considered
The real reason to split is that the backfill SKIPS ids that are absent, which is every database that is not production — a dev's local (verified: 0 lesson_packages rows, none of the five ids), a fresh `db reset`, a future staging clone. Such a database can hold its own incoherent row; the local seed historically carried exactly that shape (20260902173637, 20260917202804). Splitting 'constrain future writes' from 'prove every existing row conforms' means the first cannot be defeated by data the file was never written for, and the explicit pre-check keeps the good error message. The backfill itself distinguishes three outcomes per id — absent (NOTICE, skip; a hard failure would break `db reset` for every developer, which CLAUDE.md forbids), already at target (NOTICE, skip, so re-application is a no-op), and present-but-wrong (EXCEPTION naming found vs. expected). The template NAME is used only in messages, never as a guard: renaming a template is ordinary operator work and aborting over a corrected spelling would be a trap.
The coaching pay step follows the same rule as court booking: facilities.payment_provider NULL = no online keys = pay-at-the-club only (the facilities API already documents 'clients must offer pay-at-venue only'). New POST /api/v1/lessons/[id]/confirm-at-venue mirrors bookings/[id]/confirm-at-venue: bookings confirmed with payment_method/payment_status 'pending', no payments row, email required. It also writes the primary lesson_players row BEFORE flipping the bookings, because hold_lesson_slot creates no roster row and this path skips the OTP verify step that links one. When a provider IS set the existing Stripe card flow is untouched. Both lesson callers (Coaching tab and the legacy /book page) get it because both render StepLessonReviewPay.
Alternatives considered
Root cause was a missing branch, not a Stripe fault: the lesson flow assumed every club takes cards. Verified by TC-298 (UI → confirmation code rendered → bookings confirmed/pending, roster row carries the email, zero payments rows, no Stripe iframe) and by TC-268/268b staying green. Code-review outcome: the route flips the bookings BEFORE writing the roster row, because the lesson_players insert trigger emits a durable OpenRally lesson_created event that must never describe an unconfirmed hold; a hold owned by a signed-in user can only be confirmed by that user (403 otherwise); the roster lookup tolerates duplicate primaries (no uniqueness exists on lesson_id+is_primary). ACCEPTED, not fixed: the route does not reject clubs that have a provider (the court confirm-at-venue doesn't either, and the court flow already offers pay-at-club as an option for provider clubs, so rejecting would block the parity follow-up below); the client-trusted price_per_pax_amount in lessons/hold is pre-existing and equally trusted on the card path (the PaymentIntent amount is client-supplied too) — worth its own ticket. Known gaps, stated rather than hidden: no confirmation email is sent for pay-at-club lessons (the card lesson path does not send one from /confirm either; the existing send-booking-confirmation function is court-shaped), and fetchSlots in lesson-browse still swallows errors into 'No lessons available'.
Buckets are not_due / 1-7 / 8-30 / 31-60 / 60+, plus a separate no_due_date bucket, decided in the FACILITY's timezone. The screen defaults to ALL outstanding, sorted oldest-first and then largest-first, and the period control is offered as an optional filter that starts empty. bucketFor() (display) 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 in aging.test.ts, with a separate test proving the ranges cannot overlap.
Alternatives considered
Everything on this screen follows from the debt's age, so the two representations of age must agree or the bucket tile states a total the list under it does not contain. Pinning them against each other in a round-trip test is what makes that structural rather than hoped-for. The short first bucket is the one genuinely contestable call, and it is made on the grounds that this is a chasing tool, not a ledger — the ledger is Billing.
Reachability is resolved SERVER-SIDE per row, using the same rules sendReminder() applies, and returned with the row as a per-channel {available, reason} pair. An unavailable channel is never offered, and the greyed control carries the reason on hover — quoting the junk value when there is one, so 'ask at desk' on a profile is actionable. 'Unreachable' is also a first-class filter and a tile. bestChannel() prefers WhatsApp, and returns null rather than guessing when nothing is open.
Alternatives considered
The preflight rules already existed; the only question was WHEN they run. Running them at list time costs nothing extra (the customers are already resolved for the name column) and converts a mid-batch failure into information the operator has before they commit. The reason string is the part that makes it actionable rather than merely honest.
A four-rung ladder — collections_upcoming / _due / _overdue / _final — seeded as SYSTEM DEFAULTS (facility_id IS NULL) in message_templates for both channels, so a club edits its own wording through the existing Setup -> Messages manager. Collections hardcodes no customer-facing copy. The aging bucket picks the rung; the operator can override per send.
Alternatives considered
Four is the smallest set where a nudge, a chase, a serious chase and a final notice are genuinely different letters. Seeding them as system defaults with ON CONFLICT DO NOTHING means a club that has edited its copy never has it rewritten by a migration, and a hand-corrected default survives a re-run.
SETTLED_STATUSES moved out of reminders.ts into a leaf module, payment-requests/_lib/settled-statuses.ts, which reminders.ts re-exports under the same name (so every existing import is unchanged). Collections DERIVES OUTSTANDING_STATUSES from it by filtering the full CHECK-constraint value set, and the list route filters on that — so a settled request never reaches the screen and there is no paid row to offer an action on. aging.test.ts pins that the two lists partition the CHECK set exactly.
Alternatives considered
'Never chase a settled debt' fails in practice when two lists exist and someone updates one. One list, one derivation, and a test that fails loudly if a fourth settled status is ever added upstream. The re-export keeps the change backward compatible: the only consumer of the constant today was reminders.ts itself.
Implement it for real against the documented Graph endpoint — config gate, request body, positional parameter mapping and a sanitiser, all unit-tested without credentials — and select it per facility through a new academy_payment_settings.whatsapp_provider column defaulting to 'whatsapp_manual'. Unconfigured, it THROWS a message naming the exact missing env vars AND the Meta prerequisites credentials alone do not satisfy, and points at the manual lane that works today. SendMessageRequest gains an optional whatsappTemplate field (additive; email and the manual lane ignore it), and sendReminder gains an optional providerId (additive; omitted means the existing channel default).
Alternatives considered
The parameter ORDER is the dangerous part and the reason this is worth building now: Meta templates are positional, there are no names on the wire, and swapping two entries sends a customer the due date where the amount belongs with no error anywhere. It is pinned in CLOUD_PARAMETER_ORDER and by a test. The sanitiser is equally load-bearing: Meta rejects parameters containing newlines, and the {{payment_link}} slot carries the club's free-text pay-at-club instructions on every facility today, so without it the FIRST message this lane ever sends would fail.
Scan the REQUEST population, not the customer population. Column-level filters (facility, outstanding status, aging bucket, subject type, due-date period) are pushed into PostgREST; the resulting requests' customers are then resolved in chunks, and the two contact-dependent filters (student search, channel reachability) run in memory. The count and the page slice are BOTH taken from the fully-filtered array. The scan is paged to completeness with a hard ceiling of 5,000, and reaching it returns 400 with the instruction that shrinks it — never a truncated list.
Alternatives considered
The direction of the scan is the whole difference. A list of debts is inherently small — peak realistic volume at the live club is about 14 open requests — while the customer population is not, so scanning debts and resolving their people is bounded by the thing that stays small. The ceiling exists because a truncated accounts-receivable list understates what the club is owed, which is the one number this page exists to state, so it refuses rather than under-reports.
NO new cadence. The existing one (academy_payment_settings.reminder_offsets + enabled, already false by default, already fail-closed because the dispatcher INNER JOINs a settings table most clubs have no row in) stays the only one. What Collections adds instead is a FAIL-CLOSED fix to the dispatcher: a due reminder on a channel with no automated transport is marked 'failed' with a readable reason, and only channel='email' is dispatched. Operator-initiated chasing — which is what the screen is for — never touches cron at all.
Alternatives considered
Automatically messaging customers is not something to switch on for a live club, and a cadence built against an empty table is a cadence discovered to be wrong in production. The dispatcher fix is the opposite kind of change: small, additive, and it converts an invisible infinite retry into a visible failed row. Fail closed, report loudly, and let a human press send until there is something real to automate.
Exact, case-insensitive, facility-scoped name match (ILIKE with % / _ escaped, so a literal percent sign in a name can't be misread as a wildcard) on save (create or rename), excluding the item's own id in edit mode. On a hit: a dialog with 'Merge into existing item' SHOWN BUT DISABLED (title='Coming soon — merges sales history under the surviving item (POS-05b)') and 'Keep both' which just proceeds with the save. No new DB constraint — a soft, dismissible warning only; pos_catalog_items.name stays unconstrained so existing duplicate data isn't retroactively blocked from being edited.
Alternatives considered
The reported bug is about EXACT duplicates splitting reports (Pablo's example: two items literally named the same thing) — a soft warning with a visible-but-disabled merge path communicates the roadmap honestly without either blocking existing data or quietly reinventing matching logic that packages/common already solved for categories.
Yes, automatically, with zero extra code. apps/terminal/src/components/terminal/menu-grid.tsx's createItemOpen state (the till's '+' create-item button, opened mid-checkout) renders the exact same <ItemModal mode="create"> component apps/terminal/src/app/(dashboard)/[facility]/items/page.tsx and the catalogue detail page use — so wiring the check into item-modal.tsx's save handler covers every entry point into it, including this one, with no additional integration work. apps/pos's equivalent quick-add dialogs (menu-grid.tsx's 'Create Item' / 'Create Service' Dialog, used for on-the-fly cart lines) are a SEPARATE, lighter-weight component that calls addCustomAmount() directly — they never insert a pos_catalog_items row at all (it's an ad-hoc cart line, not a catalog item), so they are correctly out of scope for a catalog item-name warning.
Alternatives considered
ALB-45's manager-only restriction on the terminal quick-create path and this warning are orthogonal concerns (authorization vs. data-quality) that compose for free because both sit on the same shared component — no interaction to design around.
No approval column on pos_refunds, and no manager-approval step added to void or refund. Both RPCs already hard-require the ACTOR THEMSELVES to hold owner/admin/manager (VOID_ORDER_ROLES, unchanged) — there has never been a 'staff does it, manager approves it' shape for these two actions the way closing a bill briefly had (business rule 3, since removed — DEC-490). pos_activity_logs.approved_by_user_id stays NULL on every order_voided/order_unvoided/order_refunded row this migration writes.
Alternatives considered
Mirror the SHAPE that actually exists (role-gated actor, PIN-identified for display) rather than the column names a sibling feature happens to have. Close and void/refund are genuinely different: close is staff-performed with optional elevation; void/refund have always been manager-performed outright.
Widen the check from a strict equality to: NEW.voided_by must be auth.uid() OR a user who currently holds a pos_staff_pins row at the SAME facility. This still rejects the trivial forgery (an arbitrary or other-tenant uuid with no PIN at this facility) while accepting the same residual trust-model limit create_pos_order_atomic's closed_by_user_id already has: a manager with raw UPDATE access could still misattribute to a DIFFERENT real PIN-enrolled staff member, since neither this nor the close path cryptographically binds voided_by/closed_by_user_id to 'the specific PIN entry that unlocked THIS action'.
Alternatives considered
Matching the EXISTING trust model exactly (not inventing a stronger one for void/refund alone) keeps the security posture consistent and honest about its real limit, rather than quietly presenting void/refund attribution as more tamper-proof than close attribution already is.
Rebase onto #1232, never revert it. Extended all three shared components with voidedByName (all three) and refundedByName (list row + standalone rows only — a refund is payment-level, not order-level, so it has no natural home in the order-header pill) instead of reintroducing the hand-duplicated JSX #1232 had just eliminated. Extended close-attribution.ts's collectIds/applyNames centrally (one place, as the file's own header already argues) so resolveClosedByNames, resolveClosedByNamesAcross, AND resolveExportAttribution all pick up voided_by/refunded_by_user_id resolution for free — no parallel resolver.
Alternatives considered
A merge conflict against a refactor that landed later is a signal to adopt the refactor, not route around it — the alternative (reverting or working beside it) both recreate debt a concurrent session already paid down.
payment_link (manually-keyed QR) becomes editable-from (editableInOrderDetail: true in packages/common/src/payment-methods.ts, shared by apps/pos and apps/terminal). stripe, credits, prepaid and package stay non-editable. insufficient_role copy now names managers. No RPC/migration change.
Alternatives considered
Relabel is only safe where the label is the only thing tying the payment to a ledger. Manual QR has no gateway object behind it; stripe, wallet and settlement tenders do.
No. It is the same data under a contract that moved. In September the feed was the Payments TAB INSIDE MEMBERS and had to show packages only; at 4,885 drop-ins to 27 package sales it buried the packages the tab was named after, and DEC-391 was right for that screen. Since then the feed has been promoted to its own nav entry, /{facility}/academy/billing (PR #1221) — the club's money screen, not a packages screen — and the ask is now the opposite. The restoration is scoped by Nestor's last sentence: a drop-in is NOT an academy member, so Members (/academy/members — list, filters, route) is untouched; their money is academy money and Billing records it. The source is restored with its predicates pushed into the query (player_package_id IS NULL, paid_amount > 0, status NULL-or-not-cancelled), scoped through lessons!inner, windowed on joined_at, and the tiles move with it in the same change (DEC-511).
Alternatives considered
Measured read-only on production first (2026-10-13): pad-thai-padel 2,029 seats, ฿2,473,200, all class_type 'private', zero guests, zero hosted intents, joined 2025-03-03 → 2026-05-29; 3 cancelled seats (฿3,600) correctly excluded; Bali 0 seats with money. 'Events' has no entity in this schema — classes.class_type is private|group and a one-time seat on a group session is a lesson_players row — so one source covers both halves of the sentence. FLAGGED, not fixed: no drop-in seat has carried money since 2026-05-29, two days before go-live, so either externally-booked lessons stopped reporting payment or they land elsewhere now; that is a pipeline question, not a Billing one.
IN: paid package-less lesson_players seats (the drop-in itself) and payment_requests whose subject is a lesson (an invoice for one seat — zero today, but a lesson seat and an invoice for one are the same population, and the dedup rule only holds if both are read). OUT: payments.type='lesson' — settle_payment_request (20260915191501) writes that row AND sets the seat's paid_amount, so the seat is the truth row and reading both counts a settled lesson request twice; zero such rows exist anywhere, and collected_payments in the tile stays type='package' for the same reason. OUT: credit_topup — a wallet prepayment later spent on bookings, POS or a package; a package bought with credits already lands as payments.type='package' with method 'credits', which Billing lists, so counting the top-up too counts the same baht twice (23 succeeded top-ups at pad-thai-padel, ฿188,530). OUT: membership (club, not academy, recurring) and booking (court money) — neither is academy money; zero rows at either live club. OUT: package-funded seats (272 + 163 on production, every one ฿0) — paid by a credit deduction, not money; they are attendance and already have a home on this screen, the Package deductions ledger. OUT: ฿0 package-less seats (Bali's 354) — no transaction happened; the pre-removal feed listed them as 'pending ฿0', which is noise, and a real debt is the payment_request that IS listed. OUT: programmes — the Programs feature is parked; the live club has 2 enrolments and 0 payments, and payments.type='program' has 0 rows; if programmes ever carry money they arrive as that type and are the natural 'events' source then.
Alternatives considered
Each inclusion was tested against one question — does a row represent money moving for an academy transaction exactly once — and each exclusion names the row that would otherwise count the same money twice, or the reason it is not academy money. All figures read-only from production 2026-10-13, per facility, never summed across THB and IDR.
A fourth segment, 'Drop-ins', that exists ONLY on Billing, composed on top of the shared vocabulary rather than added to it. New module apps/admin/src/app/api/admin/academy-payments/_lib/billing-products.ts: BILLING_PRODUCT_FILTERS = PRODUCT_FILTERS + 'drop_in', BILLING_PRODUCT_SEGMENTS = PRODUCT_SEGMENTS + {drop_in, 'Drop-ins'} (last), and billingProductMatches(filter, product) that defers to productFilterKinds() for every shared value and adds exactly one case. PACKAGE_KINDS and PRODUCT_FILTERS in packages/common are untouched, so Enrollments still answers ?product=drop_in with a 400 — the right answer, since an enrolment can never be a drop-in — and the three package words are classified identically on both screens by construction (pinned by a test that iterates every shared filter against every kind). FeedProduct becomes PackageKind | 'drop_in' | null; a drop-in row is assigned by SOURCE (it came off a roster, or is a request on a lesson), never by reading a template, and packageProduct() can never return it. null (unresolved template) stays visible under All and claimed by no named segment, Drop-ins included. Billing now also answers an unrecognised ?product= with a 400 instead of an empty feed — the divergence the taxonomy change flagged but did not fix because it was a contract change; this change is one.
Alternatives considered
The shared filter answers 'which kind of package'; a drop-in needs its own answer rather than being forced into that vocabulary. Composition keeps the shared words shared and the Billing-only word local, which is also where Node's test runner can resolve it (./billing-products.ts) for the feed's own tests. Segment placed last because it is the one segment that is not a package.
Migration 20261012190043 (NOT applied to production; the approved path is `supabase db query --linked --file`, run by Nestor in the same window as the app deploy). collected_lessons is restored with predicates that mirror the feed's roster read one for one — player_package_id IS NULL, paid_amount > 0, COALESCE(status,'') <> 'cancelled', joined_at within the period, facility through the lessons join — PLUS an anti-join against an OPEN lesson request for the same (lesson, student), because the feed hides such a seat and shows the request instead. open_requests widens to subject_type IN ('player_package','lesson'). collected_payments stays type='package' (DEC-509). Everything else is byte-identical, including the reversal-netting rule. The feed's PostgREST read spells the status predicate as .or('status.is.null,status.neq.cancelled') because `neq` alone drops NULLs under three-valued logic and the SQL's COALESCE keeps them — the two must agree on NULL.
Alternatives considered
The anti-join is also what keeps 'Revert to due' on a settled lesson request coherent on this screen: revert_academy_payment has no lesson branch and leaves lesson_players.paid_amount in place (pre-existing gap, zero rows affected, FLAGGED), but it reopens the request, so the seat drops out of both the feed and the sum. Verified read-only on production that the new Collected arithmetic and the feed's paid rows agree for the same windows at both clubs, and that the OLD function would disagree by exactly the drop-in total — the figure the report quotes.
dropInChannel(): 'external' when payment_method is 'external', else 'payment_link' when a hosted intent exists, else 'in_club'; labelled 'External app' in CHANNEL_LABELS and added to the channel filter so it is both true and selectable. Consumer-agnostic by name: no consumer is named anywhere. The tender itself (lesson_players.external_payment_method_type, 'prepaid' throughout) is not surfaced in this change; method renders as 'Recorded externally', which is true. Row actions: 'Send reminder' and 'Copy payment link' are now hidden when isSettledRequestStatus(request_status) OR the feed status is 'paid'. The request-status gate still owns expired/cancelled, which the feed's vocabulary cannot see; 'paid' is the feed's own word for settled money and closes the gap on direct package sales and drop-in seats alike. Reminder history (a read) and Revert to due (which applies because the row is settled) are unaffected.
Alternatives considered
Both are statements the screen makes about a row; both were false for the rows this change restores. Fixing them here, in one line each, beats shipping 2,029 rows that say the wrong thing.
A right-side Sheet panel (academy-collections-detail-panel.tsx), opened by clicking any row, keyed on the PAYMENT REQUEST (GET /api/admin/collections/debts/[id]) and mirrored into ?debt=<id> with history.replaceState so it survives a refresh and can be linked — including to a debt that has since settled, which then shows the settled notice plus the full history. It answers for the customer behind that debt: every outstanding request (per-currency totals, never summed across currencies), settled payment history matched to payments rows with a punctuality sentence (buildPaymentHistory: reversal pairs hidden, each settlement counted once, lateness graded in the club's calendar day), every reminder across all their requests with channel/template/recipient/when/attempt/error/provider id, and every collection_note with author and outcome. Actions in context: chase with an explicit channel (routed through the list's own dry-run → confirm → send path), copy payment link, mark paid, add note. The panel contains NO <Table>: every list is a stack of wrapping rows. Affordances brought across from Billing: Mark paid asks for the tender (new academy-mark-paid-dialog.tsx — the row's one-click call never succeeded because the settle route refuses mark_paid without payment_method), explicit channel choice on send, reminder history as a read, Open student. Deliberately NOT brought across, because they belong to a record and not to a worklist that empties as it is worked: Revert to due (applies only to settled rows, which never appear here), the Due/Paid/All view switch and Collected tile (Collections is outstanding-only by construction), the Method and sold-via Channel filters (settled-money concepts), the package-kind Product filter (Collections already filters by subject type; kind is a Billing taxonomy), the inline Remind on request-less sales (Collections lists requests only). The list got thinner: reminder/note counts, error text and the For caption moved into the panel; columns drop by importance (Last contact at xl, For at lg, Amount/Age fold under the name below sm, bulk select below sm) so no table scrolls sideways. Measured with real Chromium: table overflow 0px at 1280, 1024, 768 and 390 (390 inside the 148px column the w-48 sidebar leaves — the shell itself still overflows there, a known foundation issue not touched).
Alternatives considered
A worklist you cannot drill into is not a worklist: deciding what to do about a debt needs the whole person. The honesty rules carried over unchanged — sent_manual reads 'Handed to staff (WhatsApp link)' with the sentence that nothing reached the customer unless somebody clicked it, 'sent' reads 'Accepted by provider', and a failed read renders as a failure, never as 'owes nothing / never contacted'. Settled debts remain unchaseable by construction: the outstanding half is filtered on OUTSTANDING_STATUSES (derived from SETTLED_STATUSES, never retyped), and every send still goes through the chase route's own dry run. Pinned by unit tests: customer.test.ts (settlement matching, reversal pairs, timezone-graded lateness, stats) and academy-collections-types.test.ts (status-label honesty, the punctuality sentence).
At risk is RELATIVE TO EACH MEMBER'S OWN RHYTHM: a member holding an active enrolment whose silence is ≥ 3× the median gap between their last 8 training days, never under 14 days, judged only once they have ≥ 4 training days on record. Constants AT_RISK_GAP_MULTIPLIER / AT_RISK_FLOOR_DAYS / AT_RISK_MIN_TRAINING_DAYS in player-subscriptions/_lib/member-metrics.ts, each unit-tested, each quoted on screen. Validated read-only on production 2026-10-13 against the same signal: fixed 21-day silence flags 48 of 79 active members at pad-thai-padel (61%); this rule flags 25 of 79 (32%), 1 of 18 at Bali, 1 of 10 at WareHaus. The 25 are real — their silence averages months, because a recurring plan with no expiry stays 'active' long after the member stops coming — which is exactly who the list exists to surface.
Alternatives considered
A fixed window encodes one imaginary member; a cadence-relative rule encodes each real one. Only a signal with a false-positive rate an operator will tolerate gets acted on.
A session is a CONFIRMED SEAT on a lesson that has started and was not cancelled (lesson_players.status = 'confirmed', lessons.status IN ('scheduled','completed'), start_time < now()); cadence is measured on distinct training days in the facility's timezone. Implemented once, in academy_member_facts (20261012191452), and stated on screen.
Alternatives considered
A confirmed seat on a lesson that was not cancelled is recorded the same way for every month and every club, which is what a trend needs; the brief asked for the signal to be chosen and justified rather than assumed.
Per enrolment: the RECORDED package payments referencing it, net of reversals, when any exist; otherwise a paid, non-complimentary enrolment counts at its purchase price (lesson_packages.price_amount); owed and complimentary ones count nothing; package payments referencing no enrolment of the member are still added. In the facility's currency only, formatted with formatPrice(amount, facility.currency). Where both sources exist they agree exactly — 79 of 79 package payments on production equal the template price, zero discounts — so the price of a paid enrolment IS the amount paid. Payments-when-present matters for recurring plans: one player_packages row is re-settled month after month, each settlement a new payments row against the same id, so counting the price once would under-count every renewal from the second month on. The detail panel lists the recorded payments labelled with the date they begin, and says that earlier enrolments are counted at their price. The facility tile sums LTV across the club's own members in the club's own currency and names the currency; nothing is ever summed across facilities.
Alternatives considered
A lifetime figure must cover the lifetime. Recorded payments are exact where they exist; the enrolment's price is the club's record where they do not, and the one place both exist they agree to the satang.
Default: a member is CHURNED CHURN_GRACE_DAYS = 30 days after their latest enrolment ended with no new enrolment; inside those days they are LAPSED. NEW means first-ever enrolment in the window (on production in October: 5 first-ever vs 18 any-enrolment at pad-thai-padel — renewals would have inflated it 3.6×). RENEWAL RATE = of enrolments that ended in the window and are past their grace window, the share whose member bought another enrolment by the day the grace closed; undecided ones are counted and shown separately, and the rate is null (not 0) until something is decided. When an enrolment ENDED is derived per status in academy_member_facts: expired → expires_at; exhausted → the start of the lesson that spent the last credit (ledger lesson_id, 86 of 92 on production), else the last deduction, else expires_at; cancelled → the mirror row's updated_at capped by expires_at (for the 12 of 18 cancellations predating the 2026-09-16 mirror this is the backfill date — a bounded, documented error); unknown → NULL, which can never be promoted to churned. All constants and rules are unit-tested and stated on screen.
Alternatives considered
Each rule is one named constant or one documented derivation, so Nestor can change the 30 in one place and every tile, row and panel follows; the screen states the definition so nobody misreads the number.
A right-side Sheet panel (academy-member-detail-panel.tsx), opened from the row, following the house idiom for detail-from-a-list (customer-detail-panel.tsx, academy-payments-reminder-panel.tsx): SheetContent sm:max-w-lg, SectionHeader size='sm' sections separated by Separator, InfoRow contact facts, small bordered tables for history, '—' for unavailable values. It carries the list's period so 'this period' means the same thing in both. The linkable form is not lost: the footer opens the existing /{facility}/academy/students/{userId} route for lesson-level depth, notes and tags, which the panel deliberately does not duplicate. The CollectionsDetail sibling was told the shape so the two screens share one idiom.
Alternatives considered
Reuse the existing idiom and the existing route; add the one surface that is missing (a glance at one person without leaving the list) rather than a second page.
Fold, don't scroll. Six columns: Player (name + tenure line), Package (name + sessions-left and expiry line — the former Sessions and Expires columns), Status (state + payment state only when not 'paid'), Activity (sessions this period vs own monthly average, last seen, at-risk badge), Value, actions. Starts moved to the panel outright. Below lg Package then Value and Activity drop (hidden lg:table-cell / md:table-cell) rather than scroll — the panel still shows everything — and the expiry moves onto the Player line so it stays in reach. Free text is bounded with max-w + truncate + title. Measured with real Chromium: document and body overflow 0 at 1280 and 768; at 390 only the known AdminSidebar w-48 shell overflow remains, identical to a page with no table.
Alternatives considered
A list row carries what you scan for; everything else is one click away. The detail view is the pressure valve that makes a non-scrolling table possible.
Nothing excluded anyone; measured read-only on production before touching code: 79 members on 89 active rows at pad-thai-padel (session_bank 68, recurring 20, credit_pack 1), player_subscriptions mirrors player_packages 183/183, every active row expires between 2026-10-31 and 2027-10-01 so the month-to-date OVERLAP period keeps all of them, product=all is an outer join with no type predicate, and the UI sends no status or product on first paint. What hid them was the list: the route pages at DEFAULT_LIMIT 50 with no ORDER BY, and the list had no pager — the default view was an arbitrary 50 of 181 rows, the Active chip an arbitrary 50 of 89, with 39 active enrolments reachable only by typing a name. Fix: the route orders by starts_at desc, id asc before the range; the list sends page and limit, shows 'Showing 1–50 of 181 enrollments', and draws the same Previous · Page X of N · Next as Billing; filters reset to page 1; an emptied page clamps to the last real one. The row unit STAYS one per enrolment and the scope note now says so ('a member holding two packages appears twice; the tiles count each member once').
Alternatives considered
An honest investigation found no missing row and a presentation that made 50 look like everything. The smallest change that removes the confusion is a pager plus a fixed order; inventing a filter bug to fix would have left the real cause in place.
Production is the authority for both function bodies. Four new migrations with CLI-generated timestamps after 20261012191452, each a no-op on prod: (1) 20261012204806 — CREATE OR REPLACE or_confirm_paid_hold with prod's verbatim body (hold-only allowlist, hold_expired guard, multi-slot UPDATE, slots_booked) plus the prod COMMENT restated; (2) 20261012204826 — CREATE OR REPLACE program_session_payload with prod's verbatim body, no COMMENT because prod has none; (3) 20261012204827 — ALTER TABLE facilities ADD COLUMN IF NOT EXISTS is_listed boolean NOT NULL DEFAULT true, the exact statement of prod's mirror row, and the 0-byte 20260811111127 file is deleted in the same commit; (4) 20261012204829 — DROP POLICY IF EXISTS 'playtomic_booking_references: service role only'. Proof: whitespace-normalised body of each file equals prod's pg_get_functiondef body, and after a local apply inside a rolled-back transaction md5(pg_get_functiondef) on local equals prod's (e304f5ce85dc1fbdd370857ef05de200 and e79efaabec1b080f2009f549458484e7) with proacl, owner and comment unchanged across the apply.
Alternatives considered
Silent regression is the risk: a local db reset or a CREATE OR REPLACE built from main would revert prod to a body that confirms payments against lapsed holds and 500s on every multi-slot booking, with no error anywhere. The cheapest honest fix is to make main carry what prod runs, prove it byte-for-byte at the deparser level, and leave production untouched.
Each tile's detail line leads with its window (Today · the period's dates · All time · the renewal window's dates), keeps a few words under the number, and carries the full definition behind an (i) beside its label. The (i) is ONE focusable button with two house surfaces: the Radix Tooltip (hover, keyboard focus) and the Radix Popover (click, Enter, Space, tap) — Radix's tooltip ignores touch by design (pointerType 'touch' returns early; focus-open is suppressed after pointerdown), so a tooltip alone would be unreachable on the iPads club staff use. The tooltip is suppressed while the popover is open. The list-level sentence (one row per enrolment; the tiles count each member once) moved under the list's count. Renewal rate stops following the period picker: it has four trailing windows ending today — 3m, 6m, 12m (calendar months, clamped to the shorter month's last day) and all time — switched on the tile with the same compact segmented control the product filter uses, and persisted as ?renewal= via history.replaceState exactly as Billing persists ?view=. The summary computes all four, so the switch is instant. No migration: academy_member_facts returns every member's full enrolment history (verified by reading the function — the enr CTE has no date predicate; p_from/p_to scope only sessions_in_window). The definition is unchanged — renewed ÷ decided, endings inside the 30-day grace excluded from the denominator and counted on screen — and the fraction is always shown; a zero denominator renders '—' with the reason. Default 3m: the first window Nestor named and the one closest to the month-to-date period the tile used to follow.
Alternatives considered
An operator reads a tile and understands THAT tile. Every fact from the paragraph survives, next to the number it describes, and the renewal window is a property of the renewal tile rather than of the screen. Measured on production read-only with the real code: September endings reproduce exactly (7 of 11, 27 in grace); all four trailing windows at pad-thai-padel read 16 of 29 decided (55%) today because the club's earliest ending is July 2026 — the windows will diverge as history accrues, and the fraction on screen says so.
(1) ONE ledger, two hosts: AcademySessionsLedger gains an optional `period` prop; when Members passes the screen's period the ledger renders no picker of its own, when Billing passes nothing it keeps its own. No second deductions table. (2) The switch is the house segmented tablist (the pill Billing's Due · Paid · All and the list's product filter already use), persisted as `?view=deductions` with history.replaceState exactly as Billing persists its segment; the default is not written. The pure parse/write rule is lib/members-view.ts under unit test. (3) The tiles STAY in both views: they are the screen's, like the period, and hiding or swapping them would make a table switch read as a page change — which this explicitly was not to be. The two things the views disagree on are SAID on screen in a note shown only in the deductions view: the period means overlap for an enrolment and the lesson's date for a deduction (past lessons only), and a deduction is written at booking, so the Sessions-per-member tile (confirmed seats) and the ledger (package credits, 829 seats vs 183 on a package in 30 days on production) measure different things. (4) The ledger's scroll sentinel is replaced by the members list's Previous · Page X of N · Next with pageWindow, bounded by the server's window rather than by total; both ledger routes gain an `id` tie-break under created_at. Nine columns measured 1,087px against a 1,038px shell at 1280, so the row folds to seven with nothing lost (type under class, balance-left under the deduction) and drops Coach/Package below xl and Session below lg. (5) The detail panel gets a 'Package deductions' section fed by the per-student route under a new `scope=deductions` param that applies the facility ledger's own LEDGER_SCOPE_OR in the database before count and range — one definition of 'deduction' — rendered as the same compact divide-y list as Enrolment history, never a <Table>, 50 newest with an honest 'showing N of M' line, its own load/empty/error states, and 'Lesson still ahead' on a row whose lesson has not started.
Alternatives considered
Reuse over rebuild: the capability existed and only its placement was wrong. Every place the two screens genuinely differ — period semantics, what a 'session' counts, booking-time deductions on lessons still ahead — is written on the screen rather than reconciled by pretending; and every count the operator reads (pager, panel total) is the database's, taken with the filter applied.
(b) A separate Academy → Revenue screen directly below Billing, fed by ONE SQL function, academy_revenue_summary(), that is the investor definition adopted verbatim from the investor session's own query: item level (pos_order_items.total_cents), GROSS of order-level discounts (the discounts on the carrying orders are returned beside the figure, unallocated, and the net floor is printed), completed non-voided orders keyed by COALESCE(business_date, closed_at in the facility's timezone), a line counted when its LEAF category name — or, for a line with no category, its item name — contains academy, coach, lesson or package (case-insensitive). At pad-thai-padel that is exactly 'Academy Programs' + 'Group Coaching (Events)' + coaching-named uncategorised lines, and NOT Weekly Events, PTP Championship, Privatization (Event Fee), Event Prices F&B or Staff Support (Events), which the investor pages report elsewhere; at pad-thai-padel-bali exactly 'COACHING LESSON' + 'Academy Programs', not 'EVENTS'. Verified against every category at both clubs; reproduced read-only on production before the function existed: Sep 2026 ฿678,713, 1–14 Oct ฿307,420, Bali Sep Rp 326M — the figures on the investor pages, to the satang. The rule is one word list applied to category names and uncategorised item names alike, returned by the function (match_terms) so the screen quotes it; p_include widens or narrows the category set for a 'plus events' reading and is_default_definition makes the screen say, under the number, that a widened figure is NOT the investor definition. The selection is URL state (?include=), never persisted, so nobody can quietly move the default and a shared link carries its boundary. Everything is one SQL statement because PostgREST caps responses at 1,000 rows and this product has shipped two silently-partial JavaScript sums. Every sold line in the period is on screen — included, not included, coaching without a category (its own line, because it is a heuristic that depends on catalogue discipline), or no category and not coaching-named — and the browser re-checks four identities (lib/academy-revenue.ts reconcile) and prints a red finding if the payload does not add up. Anything registered in the POS is paid (Nestor), so there is no Due/Paid switch and the figure is labelled collected. Academy records that never reached the till are listed BESIDE the figure, never inside it: succeeded academy payments with no mirrored POS order (source_id is prefixed 'academy_payment:<payments.id>'; every such row on production is payment_method 'pending' — a payment-link sale — or 'credits', both skipped by the bridge by rule, 20260930104333 lines 244–276) by method, and drop-in seats marked paid on rosters (฿2,473,200 of imported Mar 2025 – May 2026 history, before the POS existed); plus Billing's own Collected for the same instants, obtained by calling academy_payment_summary rather than re-implementing it. No double count is possible: the figure reads pos_order_items only and the payments table never contributes to it. Read-only; nothing in the money recording path changes.
Alternatives considered
An investor-facing number must carry its own definition and be reproducible from the database it claims to describe. One SQL statement is the definition; the screen quotes the rule it used, itemises the figure down to every sold line in the period, says in words whether what it shows is the investor's boundary, and lists what it deliberately leaves out with the reason. Measured, not assumed: the three reference figures were reproduced on production before any code was written, and the seed used for the recorded demo reads 17,875.00 = 13,375.00 at the till + 4,500.00 mirrored, with 820.00 not included and 150.00 uncategorised adding to every line.
Cause: create_pos_order_atomic (introduced by 20260904103554_pos_checkout_idempotency and every rebuild since) INSERTs "source" explicitly as p_order->>'source'; neither apps/terminal nor apps/pos ever put a source in the order JSON, so the column receives an explicit NULL and the column DEFAULT never applies. The discontinuity is the day that RPC reached production. Attribution only — no money is wrong, and the Academy → Revenue figure classifies by category and item name and never reads source (the mirror is identified by source = 'academy_payment', which the bridge sets itself). NOT fixed here: the checkout RPC is the live money recording path, frozen by CLAUDE.md §5 and replaced by another migration the same night. The fix is Nestor's to build: in create_pos_order_atomic, COALESCE("p_order"->>'source', 'pos') on that one VALUES line (or, independent of the function, a BEFORE INSERT trigger on pos_orders setting NEW.source := 'pos' when NULL), plus a one-shot backfill UPDATE pos_orders SET source = 'pos' WHERE source IS NULL AND channel = 'terminal' AND status IN ('completed','voided') — a production write Nestor runs in daylight, with a count before and after (expected 1,566 at pad-thai-padel; the Bali club should be counted first). Until then any report that groups by source must COALESCE(source, 'pos') and say so, rather than print '(none)' to a club owner.
Alternatives considered
A silent NULL on two million baht of orders is exactly the class of money bug §5 exists for: no error, no missing money, just a label that quietly stopped being written. It is recorded here with its cause and its one-line fix so the decision to apply it is taken by the owner, awake, and not folded into a reporting change.
(1) The tile's HEADLINE is academy_revenue_summary(facility, date_from, date_to, NULL).included.gross_cents — the investor definition (item level, gross of whole-order discounts, completed non-voided orders by the till's business day, leaf category or uncategorised item name containing academy / coach / lesson / package), with p_include NULL so a widened selection can never leak into Billing. The summary route calls it in parallel with academy_payment_summary; no new function, no migration — the POS function is already applied on production. (2) The records figure survives, demoted: the line under the number names the mirrored share ('฿4,500.00 of it mirrored from Academy records'), and the (i) beside the label — the house MetricHelp pattern lifted from Members into components/admin/metric-help.tsx — spells out the definition and quotes what the academy's own records show for the period and how much of that reached the till. The (i) is a button, so 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 unchanged. (3) The Due side is untouched: Outstanding, Overdue, Renewals, the feed, BILLING_PRODUCT_FILTERS and the drop_in guard all still read the academy tables — receivables are their job, and the POS knows no student, no due date and no request. (4) Two clocks, said on screen: Collected follows the period by the till's BUSINESS DAY; the list and the records figure are keyed by when each record was created (instants in the facility's timezone). The scope note states both, and states that the list is a different population (academy records, not POS lines) that does not add up to the tile — the residual difference is structural and accepted; Revenue itemises the tile, which is where the tile's click now goes instead of filtering the list to Paid (a Paid list that no longer sums to the tile would be the old defect in a new place). (5) Failure: lib/academy-billing-collected.ts reduces both payloads to three nullable numbers in one place; a failed POS read renders '—' with the reason and NEVER falls back to the records figure, which would silently put the 7×-smaller number back under the same label; a failed records read nulls only Outstanding / Overdue / Renewals and the secondary line. (6) Reconciled read-only on production 2026-10-14 04:39 Bangkok: September included 67,871,291 satang = ฿678,712.91 → ฿678,713, the investor figure, mirrored ฿57,500, records ฿282,000 (of which ฿197,500 are payment-link/credits sales with no POS order, 0 drop-ins); 1–14 October 33,512,033 = ฿335,120.33, mirrored ฿98,875, records ฿128,375. The brief's ฿307,420 for 1–14 Oct was measured on 13 Oct before that day's till sales: the six academy lines dated 13 Oct (12:36–21:43 Bangkok) sum to exactly ฿27,700, and 307,420.33 + 27,700 = 335,120.33; 1–13 Oct returns the same 33,512,033 because no 14-Oct sale had closed at 04:39. Every satang is accounted for. Revenue's 'How this relates to Billing' paragraph is reworded — it said 'Billing's Collected tile reads ฿X' about the records figure, which became false the moment the tile moved — and its testids are kept.
Alternatives considered
Two screens that print different numbers under the same word are a reporting bug even when both numbers are individually correct. The investor definition already treats the POS as the academy's truth, Nestor confirmed it, and the function that computes it is applied and verified; so Billing reads it too, keeps its receivables job intact, says which clock and which population each figure follows, and refuses to ever show the smaller number as the headline again — measured on production to the satang, with the one apparent discrepancy traced to six till sales and a timestamp.