Setpoint · Academy · Pad Thai Padel
Proposal for Nestor · 16 September 2026, Feature M added 17 September · nothing in this package is built

Setpoint Academy as a monthly-subscription academy system

Turn the Academy module's packages into subscriptions anchored to the 1st, separate what is scheduled from what is done, and make rosters follow the active package. Thirteen features, PR-sized, read-only first, on the module that already exists. Feature M, added 17 September, covers the day-to-day operations view for coaches and staff.

Prepared by Alvaro with an agent, on the Academy Product Plan of 16 Sep Ground truth SETPOINT-AUDIT-2026-09-16, PHASE-1-SPEC, local schema (read-only) Companion files 00-context · 02-technical-spec · 03-plan · 04-decisions · mockups/
24 / 30reference features the Academy module already covers (12 complete, 12 partial)
22 PRsone branch each; first seven are read-only or flag-off; about 151 agent hours + 40 review hours
1 Nov 2026proposed ledger switch for Pad Thai Padel, after a reviewed dry run

The problem, in Alvaro's words

Packages must become monthly subscriptions with automated payments.

Today a session is deducted from a package the moment a student is added to a class. That destroys visibility: nobody can tell what is scheduled vs what is actually done, so nobody knows what is used, what is left, or what to charge.

Mid-month joiners pay full, then the next month is unfair. We need a one-time proration bridge so every student is clean from the 1st of their second month.

Rosters and schedule must follow the active package: active package → in the group and on the schedule; lapsed → out, automatically.

Added 17 September. The PWA where coaches and staff see the calendar and record expenses is not really useful. Bookings, whatever the type, don't have the information we need: players are not displayed, a user can't add a player or student, and creating a booking doesn't let you say whether it is an event, a court booking, a lesson or group coaching. You can't filter by coach. For an event like a guided americano or first-time padel you can't see who is joining or the waitlist, which live on TPS, and you can't see whether a coach is assigned, in Setpoint, in the PWA or in TPS. Coaches, community managers and staff need one place to see this and manage certain things.

What the code confirms (paths in 00-context.md): create_lesson_atomic is the only caller of consume_package_session, so a session leaves the balance when the lesson is created; lesson_players.checked_in_at and attended exist but never touch the ledger; process-package-billing raises a payment request and grants no sessions; groups have no link to classes or lessons; there is no proration, no reservation concept, no lead entity, no audit log, no CSV export in admin.

What the code confirms for the operations side (17 Sep, paths in Feature M): the Staff PWA is Flutter (apps/calendar_pwa); its filter chips are courts plus Court / Coaching / Other and a "Show Available Slots" toggle, so an event is a court booking or "Other"; creating from a slot offers Book Court, Book Coaching or Set Availability, and Book Coaching is one class plus one student; a booking's detail shows a coach only when coach_name is set and players only when a lesson is linked; there is no "add player". The admin calendar's "Events" filter is a label on guest_booking_category='event', which its own code comment says "is NOT the parked Events feature". Neither app filters by coach. Setpoint does have an event entity with a roster, a waitlist and an instructor (programs, program_enrollments) that already mirrors to TPS both ways through OpenRally, but americanos and first-time padel are created on TPS, so they land here as anonymous court bookings, and the mirror payload carries no coach.

What the data shows (10 Sep exports): 96 active packages across 112 students, all billing_mode='manual'; 469 lessons in six weeks with 221 cancelled; 698 roster seats with 52 check-ins. The ledger cannot be trusted for "used vs left" because attendance is almost never recorded and consumption happens at scheduling time.

Target model

Full definition with column names in _target-model.md; red-team findings and design responses in 02-technical-spec.md. Decisions assumed: the defaults in 04-decisions.md.

Subscription

Session ledger: reservation separated from consumption

Reserved (scheduled) · outlineConsumed (attended) · filledNo-show · struck, still consumedReleased · faded
attendance_stateMeaningLedger effectWho sets it
scheduledOn a future rosterReserved. A locked, package-attributed fact on the seat row (lesson_players.player_package_id), counted against the balance, no ledger row.Roster generation, or staff
attendedChecked in or marked doneconsume row, delta −1, metadata.attendance = 'attended'Coach (PWA check-in), manager, or the auto rule when enabled
no_showFixed slot missedconsume row, delta −1, metadata.attendance = 'no_show'. The slot is lost.Coach or manager; auto no-show only once check-in is habitual (D9)
cancelled_by_academyLesson cancelled or seat released by staffReleased; refund only if already consumedLesson cancel cascade, roster sync, staff
cancelled_by_studentStudent cancelledGroup: consume (deducted, D1). Individual: released if more than 24 h before, else consumed (D2, reusing cancellation_policies.free_cancellation_hours)Student, or staff on their behalf

Balance = purchased − Σ consume rows ± adjustments. Reserved is shown beside it, never subtracted from it: "6 · 2 used · 3 reserved · 1 free". One consume reason keeps every existing reader correct; the flavour lives in metadata. The reservation cap (reserved + consumed ≤ purchased) is enforced under a lock in the statement that inserts the seat, and get_player_package_remaining_sessions becomes reservation-aware before any flag flips.

Migration from deduct-on-add: an academy-level flag academies.ledger_mode (on_add today, on_done target) so no live club changes; a dry-run report; a one-transaction switch keyed to the report snapshot that writes ledger_mode_switch rows (reversible, never nulling the package link); past lessons default to attended, never inferred as no-show; Jose signs the report. Detail in 02-technical-spec.md §12.

Roster follows package

Month-end billing

DayWhat happensExists today
26Renewal notice for every active recurring package (own dispatcher, because reminder offsets are relative to the due date, not the 1st)new
1process-package-billing creates the period's payment_requests row and issues it: Stripe payment link (card + PromptPay) plus pay-at-club instructions. No off-session charge in this release; a saved-card charge is a later per-student opt-in (D5).Request row and reminders exist; issuing a link is new
3Unpaid → past_due, reminder. Student keeps attending.new state
7Booking freeze: no new roster adds. Existing seats stay until grace ends (day 10).new
AnyBridge for a mid-cycle start: amount = round(price × min(floor(days_to_next_1st ÷ 7), 4) ÷ 4). 3 weeks PRORATE75 · 2 PRORATE50 · 1 PRORATE25 · 4+ full · 7 days or fewer: wait for the 1st. Once, on the first request, then full. Session banks never bridged.Spreadsheet rule today; the worklist (Step 1) shows it before billing charges it (Step 3)

The "What is due" view reconciles three sets every day: scheduled, not paid (reserved seats on an unpaid request), attended, not paid (consumed seats on an unpaid request), paid, not scheduled (paid period with no reserved seat: a churn signal).

Feature scope A–M

Each feature: product requirement, technical requirement, mockup. "Exists" links to the audit row; "New" is what the PR adds. Roles and autonomy per feature follow the Plan: AUTO the software computes and flags · STANDING RULE Abby executes what a list shows · DECISION Alvaro (money) or Jose (sport) · NEVER the software takes or refunds money on its own, deletes, or messages an opt-out.

A

Planning calendar with drag-and-drop

Mockup A

Product

  • Drag any card (court booking, academy group, individual) to a free court/time; drop on an occupied slot offers "swap courts".
  • Conflicts use the existing coach and court rules and refuse with the reason; override needs a reason.
  • Undo last move; keyboard and touch fallback (select card → arrows → Enter); every move is an audit entry.

Roles: manager may move; override is DECISION (Jose for coaching, Alvaro for courts). AUTO: conflict detection.

Technical

  • Reuse PATCH /api/admin/lessons/[id] (moves the lesson and every court leg, runs findCourtConflicts, 409 on clash) and PATCH /api/admin/bookings for court bookings; the handler branches on card type because the bookings route refuses lesson legs.
  • dnd-kit v6 is already a dependency; the week grid is a clean function of tick index (30 min, 28 px) and court index; Calendar and Planning share the same grid components, so one implementation lands on both.
  • Fix first: the lessons PATCH drops availability_override (editableFields omits it), so a move outside coach hours cannot carry an override today.
  • No push to coaches exists; the move writes an audit row and, once J ships, a message.
B

Groups linked to schedule

Mockup B

Product

  • A group has slots (weekday, time, duration, court preference, coach) and a class template.
  • "Generate lessons" for a month; roster by package coverage; "needs package" members excluded unless overridden with a reason code.
  • Lapsed members leave future rosters after the grace window; a waitlist offers released seats with a timed claim.

Roles: generate = manager (STANDING RULE); override = owner/admin (DECISION); waitlist offer = AUTO.

Technical

  • Today academy_groups is roster + free-text level only, explicitly not linked to classes or lessons (migration header 20260722032430).
  • New academy_group_slots, academy_groups.class_id, lessons.academy_group_id; generation extends api/admin/lessons/recurring and its series cap must read the reservation-aware balance; two-slot groups spread seats across the month.
  • Persisted package selection on the seat; advisory lock per package in the insert; lesson_waitlist modelled on the parked programs waitlist (program_enrollments, timed claim).
  • Nightly dispatch_roster_sync (pg_cron) with the paid-seat exclusion and a 24 h cutoff; roster changes already emit OpenRally events (emit_group_lesson_player_event), so consumers see them.
C

Session ledger: scheduled vs done

Mockup C (desktop + coach mobile)

Product

  • Student page: package, balance, reserved, consumed, history with reasons and actors.
  • Lesson page: attendance state per player; one tap "Done" / "No-show" for the coach; manager correction within 48 h with a reason.
  • The four visual states are the language everywhere: outline, filled, struck, faded.

Roles: mark done = coach or staff (D8); correction = manager with reason; auto no-show = club setting, off until check-in is habitual (D9).

Technical

  • lesson_players.attendance_state (CHECK), attendance_marked_at/by; new RPC mark_attendance writes the consume row in the same transaction (DEC-319/320 pattern) with the flavour in metadata.
  • academies.ledger_mode flag; on_add keeps today's path; on_done makes create_lesson_atomic and the group-seat RPCs reserve instead of consume; exhaustion moves to done time; the PWA check-in (qr_checkin_screen.dart) gains the two buttons via the existing coach RPC family.
  • Renewal boundary: a seat consumes under the period of the lesson date; reset_package_sessions must not zero next-period reservations.
  • Backfill and switch: §12 of the spec; inverse migration written and tested first.
D

Monthly subscriptions and automated payments

Mockup D

Product

  • Subscriptions view: state per student, period, next request, last payment, pause before the 26th.
  • On the 1st the system issues every request with a payment link (card or PromptPay) and pay-at-club instructions; day 3 past due; day 7 freeze.
  • Settings card with every rule visible.

Roles: issuing = AUTO; pause = DECISION (Alvaro) executed by Abby; a saved-card charge is opt-in per student, later.

Technical

  • process-package-billing already rolls the cycle, writes payment_requests (unique per subject + period), queues reminders and advances next_billing_date; it grants no sessions and mints no link (AC-41 skips stripe_subscription_id rows, which keep their invoice.paid path).
  • New: mint a Stripe Checkout/Payment Link per request (checkout_url), webhook checkout.session.completed → payments row (the POS bridge trigger fires) → settle request → grant sessions; past_due and paused states; dispatcher selects active or past due; resume re-anchors.
  • No off-session charge: customers.stripe_customer_id exists but no SetupIntent or saved method is ever collected, and most academy money is cash, transfer or PromptPay (red team). Opt-in saved-card charge later, with an idempotency key from (package, period) and a persisted attempt row against the hourly re-fire.
  • Gate: Setpoint's Stripe on live keys for Pad Thai Padel.
E

Proration bridge

Mockup E

Product

  • Step 1 (Academy Health) shows the proposed bridge and tier on the worklist before anything is charged.
  • Step 3 puts the same computation inside billing: the first request of a mid-cycle start carries the bridge amount and tier; then full price.
  • Session banks never prorated; 7 days or fewer waits for the 1st.

Roles: the number is AUTO; Abby sends what the list shows (STANDING RULE); any other amount is Alvaro's DECISION.

Technical

  • compute_bridge(price_amount, days_to_next_1st) in packages/common and mirrored in SQL; payment_requests.bridge_tier, bridge_days; proration_mode setting (none | weekly_quarters).
  • The current function writes amount_amount = template.price_amount unconditionally; the branch is new. Amounts are satang.
  • PRORATE codes stay on Alvaro's external Stripe links until Setpoint mints its own links (D); then the bridge is the request amount itself and codes are not needed.
  • Gate: one real cycle (1 Oct) checked against Step 1's list.
F

Payment reminders that actually send

Mockup F (desktop + Abby mobile)

Product

  • Email keeps sending (Resend). WhatsApp moves to the Meta Cloud API with approved utility templates; the wa.me manual lane stays as fallback.
  • Schedule tied to billing states: day 26 renewal, day 1 request, day 3 past due, day 7 freeze notice.
  • Queue with status per message and error text; opt-out respected.

Roles: AUTO sends approved templates; anything custom is Abby via wa.me (STANDING RULE); NEVER message an opt-out.

Technical

  • Provider seam exists (apps/admin/src/lib/messaging/providers/): email real; whatsapp-manual returns the wa.me link and records sent_manual; whatsapp-cloud throws by design (DEC-323).
  • Requirements: verified Meta business, dedicated number, approved templates (utility category), per-message pricing; the free 24 h reply window ends 1 Oct 2026, so every reminder is a paid template message.
  • payment_reminders already has channel, template key, status, idempotency (request, template, channel, instant); new template keys and an opt-in table.
  • Gate: Meta approval. If it slips, everything still works on the manual lane.
G

Stripe reconciliation

Mockup G

Product

  • Nightly report: payments ↔ package or lesson ↔ POS row; unmatched, duplicates (same amount within 3 days), POS variance.
  • Attach a Stripe payment to a recorded payment, or mark cash, with a reason.

Roles: matching = AUTO; attaching = Abby (STANDING RULE); write-offs = Alvaro (DECISION).

Technical

  • Step 1 ships the in-database version (payments without a provider reference, duplicates). The persistent version is a report table refreshed nightly, keyed on payments.stripe_payment_id and payment_requests.checkout_id, joined to the POS mirror (pos_orders channel academy).
  • Partial payments: the POS bridge mirrors each payments row as a complete order; the report flags requests with more than one payment.
  • Real Stripe verification needs live keys (gate); until then the control panel's restricted-key pull is the source.
H

Attendance vs payment

Mockup H

Product

  • Per student per month: training without paying, and paid but not training; badge on the student page; month toggle.
  • Primary action: issue requests to the unpaid list.

Roles: AUTO flags; Abby issues (STANDING RULE); exceptions are Alvaro's.

Technical

  • Step 1's tab first (read-only join, Group Coaching name rule included). Persistent: a view over lesson_players × lessons × player_packages × payment_requests, materialised nightly, plus one badge component.
I

CRM with a lead entity

Mockup I

Product

  • Lead → contacted → trial → converted or lost; source (website Sheet, Instagram, walk-in, referral), owner, next action and due date, waiting days.
  • Convert creates no duplicate person: links to the existing customer by phone or email or creates one.
  • Import from the website Sheet with phone dedupe.

Roles: Abby owns every lead (D12); price exceptions escalate to Alvaro.

Technical

  • No lead concept exists; facility_customers is not in production (two migration headers record it), so leads link to users/customers through crm_customer_directory semantics, not to that table.
  • New leads and lead_events; conversion RPC; importer for the Sheet CSV export the control panel already reads.

Product

  • Message a lesson's players or a group from the page: templates for reminder, schedule change, cancellation; per-recipient channel; opt-outs greyed.
  • Sent history per lesson. No push in this release (D14): email and WhatsApp.

Roles: templates = AUTO or STANDING RULE; custom text = Abby; NEVER an opt-out.

Technical

  • Reuses message_templates (one seeded key today) and the provider seam; adds keys, a message log, and an audience resolver from lesson_players / academy_group_members.
  • Push does not exist anywhere (the coach PWA is Flutter with no messaging); deferred.
K

CSV export

Mockup K

Product

  • Export bookings, transactions (payments + ledger), lessons; filters: date range, type, coach, group, package; columns include total price and coach hours; per-coach hours summary.

Roles: any staff role; read-only.

Technical

  • Zero CSV export in admin today; POS has one and the shared csvCell helper is importable. Streaming endpoint, 12-month cap, no stored files (D15). Coach hours = lesson duration, not a timesheet.
L

Audit trail

Mockup L

Product

  • Who changed what, before and after, on rosters, packages, payments, schedule moves, attendance corrections; reason shown; filter by entity, actor, date; read-only; CSV.

Roles: owner and admin read; every write feature above writes.

Technical

  • No admin-wide log exists; pos_activity_logs swallows errors by design and a decision entry rejects that for anything with stakes. New admin_audit_log written inside the same transaction by a helper; the reversal pattern on payments.reverses_payment_id is the model.
  • Ships early (PR 4) because B, C, A and D depend on it.
M

Operations view: one card, one truth

Mockup M (admin calendar + Staff PWA)

The phone side of this feature, screen by screen, is in The Staff PWA section with its own mockup (N).

Product

  • Card contract. Every card on the Staff PWA calendar, the admin calendar and the admin lists states five things: what it is (Court · Private lesson · Group coaching · Event · Match), who coaches it or Unassigned, who is on it as confirmed / capacity, how many are on the waitlist, and where it came from (Setpoint or TPS) with its payment state. Same fields, same order, on every screen; a card never shows "Guest" for a coaching or event row.
  • Type chosen at creation. Creating from a slot asks "what is this?" first. Event is a program occurrence that mirrors to TPS so players join there; group coaching is a class with a roster; court stays a court booking. Match is created by players in TPS and only shown. The internal "event" guest label is retired once every event is a program.
  • Filter by coach in both apps, with Me and Unassigned chips; the type filter gains Group coaching and Events. Unassigned is a worklist, not a state: Abby owns a daily "Unassigned" queue on Academy Health, and any event within 48 h with no coach is named in the daily schedule message.
  • Add a player from any card in the PWA by name, phone or email, or as a guest. A full event never overbooks from the phone: the player goes to the waitlist with a position, and promoting past capacity is an owner/admin confirm with a reason. Staff and manager add to any card; a coach adds to their own lessons only, never to an event (D20). Payment shows as one of three chips, Paid on TPS · Due at desk · Unpaid, so a prepaid player is never charged twice and a walk-in who owes is never waved through.
  • Coach on events, both ways. Assigning a coach on an event in Setpoint makes them a co-organiser on the TPS event. A TPS co-organiser whose email matches a Setpoint coach is suggested, and becomes the coach once that coach or an admin confirms the link the first time; after that the pair is remembered. "Reassign coach" is one action that updates both sides together, so a late Jose covered by Elisabeth shows Elisabeth on TPS within the minute.
  • Events created on TPS are imported as programs marked from TPS with roster and waitlist read from TPS, read-only until the club switches event creation to Setpoint (D19).

Roles: AUTO card fields, source, waitlist position, coach match by email, the unassigned worklist · STANDING RULE (Abby) add players, assign the coach the schedule names, clear the Unassigned queue daily · DECISION Jose picks the coach when two are free and confirms a new coach link; Alvaro decides when event creation moves to Setpoint and any overbook past capacity · NEVER the software charges a player added at the desk, overbooks from the phone, deletes a TPS registration, or changes a coach without an audit row.

Technical

  • Staff PWA (apps/calendar_pwa, Flutter): widgets/filter_bar.dart offers Court / Coaching / Other and normalises every lesson-like type into coaching (_normaliseType, lines 35–36), so an event is "Court" or "Other"; it has no coach chip; services/data_service.dart already loads coaches (line 230), so the chip row is a filter on data the app holds. widgets/slot_action_sheet.dart (Book Court / Book Coaching / Set Availability, lines 120–147) gains Event and the private/group choice inside Book Coaching (classes.class_type is already fetched). widgets/booking_detail_sheet.dart shows Coach only when coach_name is set and its _PlayersSection only for lesson-linked bookings; it gains the five-field header, an events roster from program_enrollments, and an "Add player" sheet reusing the customer search from create_booking_sheet.dart. Fix first: booking_detail_sheet.dart prints notes raw, so the recurring-series tag reaches staff; strip it with the same regex the admin app uses (lib/recurring-bookings.ts). No push (D14): the unassigned worklist rides on the daily Slack schedule and Academy Health.
  • Admin: lib/booking-category-filter.ts defines Court Booking / Events / Coaching where "events" is guest_booking_category='event'; it gains group (lesson class_type='group') and event (program occurrence) predicates, and a coach multiselect beside calendar-category-filter (testid calendar-coach-filter). calendar-grid.tsx already carries coach_name and player_count on cards; it adds capacity, waitlist and source. Programs have no per-occurrence row: the mirror emits one session per program from program_next_occurrence (earliest slot from today, 20260616000100 lines 37–52, 92–131). So an americano or first-time padel is a one-day program (one row = one occurrence) and a recurring clinic renders per date with an occurrence key program_id + date; a true occurrence table is a later decision, called out under D19.
  • Events = programs. programs (types clinic / league / round_robin / camp / group_coaching / other, instructor_id, max_participants, court_id) and program_enrollments (enrolled / waitlisted / cancelled, waitlist_position) are already two-way with TPS through OpenRally: program_session_payload mirrors out, confirm_program_enrollment and cancel_session_enrollment_by_guest take TPS joins and leaves, and admin enrolls emit session_joined / session_left to TPS event_participants (migration 20260617140000). New: programs.source and programs.external_event_id (M-M1). Echo guard: the outbound mirror stamps external_event_id from the TPS acknowledgement in the same transaction and the importer skips any TPS event carrying Setpoint's origin marker, so a mirrored event can never come back as a second program. Capacity and waitlist have one writer: program_enrollments once source='setpoint', TPS once source='tps'; TPS deleted=true maps to a cancelled program, never a delete. americano and first_time added to program_type.
  • Coach both ways. program_session_payload carries no instructor today (zero mentions in 20260616000100 and 20260616120000); M-M3 adds instructor (id, name, email) to the payload and TPS maps it to event_organizers by coach email, the same convention the daily schedule bot already uses. Inbound, a co-organiser email that matches coaches only suggests: instructor_id is set when the coach or an admin confirms the link once (email equality is not identity: TPS users have no Setpoint auth row), and the confirmed pair is stored so the next event matches automatically; no match leaves the card Unassigned with the name shown. emit_program_event inserts into or_events with no dedupe key (20260616000100 lines 171, 226), so the instructor change gets a "did it actually change" guard before it emits.
  • Source on bookings. Setpoint bookings carry no TPS reference (TPS holds booking_third_party_references); M-M2 adds bookings.source ('setpoint' | 'tps') written by the OpenRally confirm RPC so a court card can say where it came from. Payment state reads the existing payment_status / payment_method; a player added at the desk is pay_at_venue until Abby records the payment.
  • Roles and tests. Add-player uses the existing coach and staff RLS on lesson_players (20260721091646); for events, program_enrollments INSERT/UPDATE is granted to owner and admin only today (initial schema lines 9439, 9650), so M-M4 adds a scoped policy for manager and staff on the enrollment columns only (never external_payment_ref or hold_expires_at); coaches get no write on events and are limited to lessons where lessons.coach_id is theirs. Playwright: one spec per surface with data-testid on card fields (card-type, card-coach, card-players, card-source).

The Staff PWA: what coaches and staff will see

The Flutter app at apps/calendar_pwa, installed on coaches' and staff phones. Feature M supplies the data (the five card fields, events as programs, coach on both sides); this section is the phone itself, screen by screen. Mockup N draws each screen in the app's own theme (Inter, teal 0D9488, grey EEF2F7, coloured court columns), not the admin kit. Ships with PR 20 (card fields, coach filter, typed filters, the recurring-tag fix), PR 21 (event sheet, coach on events) and PR 22 (Add player). Coach attendance and feedback, QR check-in, availability and expenses stay exactly as they are.

Mockup N · Staff PWA, six screens  ·  Feature M  ·  Mockup M (admin side)

Today on the phone · five captures, 17 Sep 08:28

  • Calendar, day view. Three court columns, cards show a name, a duration and a time, "with <coach>" on lessons and Group / Confirmed badges. Four rows that are events on TPS ("MATCH WITH THE NATIONAL TEAM", "Certified Level…", "Exhibition…", "Deuce" on all three courts) look exactly like a court booking with the event name as the guest name: no players, no coach, no capacity.
  • "MATCH WITH THE NATIONAL TEAM" sheet. Time, date, one phone, one email, payment THB 1,750, Check In, Cancel booking. The phone and email are the TPS host's, shown as if they were the customer. Nothing says it is an event, who coaches it, or who is playing.
  • "Deuce" sheet. A recurring court booking whose Notes line reads the raw series tag [recurring:<uuid>]. The admin app strips that tag (lib/recurring-bookings.ts, bookings-list.tsx); the PWA prints it.
  • "Bronze A Group Coaching" sheet (the coach's own lesson). Four players with tap-to-mark attendance, "0 of 4 present", Mark Complete, Edit time, Cancel Group. This is the only place players appear, and it has no coach line, no package balance, no waitlist and no Add player.
  • Filter sheet. Courts, Booking Type as Court / Coaching / Other, Display as Show Available Slots. No coach anywhere.

Screen by screen

ScreenToday (captures)After Feature MPR
1 · CalendarThree court columns; a card is a name, a duration, a time, "with <coach>" on lessons. A TPS event is a court booking with the event name as the guest.A coach chip row under the week strip: All · Me · Unassigned (n) · each coach. Every card carries a type pill (Court · Private · Group · Event · Match), the coach or Unassigned in amber, players / capacity with the waitlist count, and source + payment pills. "Unassigned" is a chip and a count, so the desk sees it without opening anything.20
2 · Creating from a slotBook Court · Book Coaching · Set Availability. Book Coaching is one class plus one student."What is this?" first: Court booking · Private lesson · Group coaching · Event. Event asks coach and capacity, creates a one-day program and mirrors to TPS so players join there. Set Availability stays on the coach's own screen.21
3 · Event sheetThe "MATCH WITH THE NATIONAL TEAM" sheet: time, date, one phone, one email, a price, Check In, Cancel booking. Nothing about players or a coach.Type and source in the subtitle; Coach line with Assign coach or Reassign; Players "8 of 12 joined · 3 on waitlist"; the roster with a payment chip per player (Paid on TPS · Due at desk · Unpaid); the waitlist with positions; Add player; Check in all; Message all. Read-only when the event was created on TPS and D19 has not switched.21
4 · Add playerDoes not exist. Players are added in admin or join on TPS.From any card: search a member by name, phone or email, or add a guest. A full event puts the player on the waitlist with their position; the phone never overbooks and never charges. Staff and manager on any card; a coach on their own lessons only (D20).22
5 · Coach lesson sheetThe "Bronze A Group Coaching" sheet: roster with tap-to-mark attendance, "0 of 4 present", Mark Complete, Edit time, Cancel Group. Works well; keep it.Same sheet plus a Coach line with Reassign, each player's package balance under their name ("Growth · 3 of 6 left"; "No package · due at desk" in amber), and Add player. Attendance taps unchanged; with Feature C on, a tap is what writes the ledger.20, 22
6 · FiltersCourts · Booking type Court / Coaching / Other · Show available slots.Courts · Coach (All · Me · Unassigned · each coach) · Booking type Court / Private / Group / Event / Match · Show available slots. A coach opens on "Me", staff on "All", the last choice is remembered on the device (D21).20
Fix firstThe "Deuce" sheet prints the raw recurring tag [recurring:<uuid>] in Notes.Stripped with the same rule the admin app already uses.20

Technical notes for the phone: the filter and the card fields need no new endpoint (data_service.dart already loads bookings, lessons, lesson_players and coaches; the derivation runs in Dart); the event sheet reads program_enrollments through the existing Supabase client; Add player calls the enrollment RPC or writes lesson_players under the existing coach and staff policies, plus the M-M4 policy for events. No push (D14): the Unassigned count on the chip is the phone's worklist.

Data model deltas, API, settings, migrations

Every row names a real table. The technical spec carries the columns, constraints and RPC bodies; this is the map.

Migration · spec idChangeWhyPR
M-L1 · spec M12admin_audit_log + transactional helperEvery write feature below needs an actor, before/after and a reason4
M-C1 · spec M3lesson_players.attendance_state, attendance_marked_at/by, override_reason, override_reason_code, override_byThe state the whole model reads6
M-C2 · spec M1 + M4academies.ledger_mode; reservation-aware get_player_package_remaining_sessionsFlag defaults to today; the series cap stops over-booking the day the flag flips6
M-C3 · spec M5mark_attendance RPC; consume metadata; ledger_mode_switch reason + inverse; done-time exhaustionConsumption at done time, one reason, reversible switch7
M-C4 · spec M18 + M19Dry-run report function + one-transaction switch keyed to the snapshotJose signs a report, the commit refuses rows that moved7
M-C5 · spec §12 scriptGrowth (monthly) recurring template; migration script for the 9 multi-package students and 62 NULL-expiry packagesNever re-type a template in place7
M-B1 · spec M6 + M7academy_group_slots; academy_groups.class_id; lessons.academy_group_idGroups get a schedule8
M-B2 · spec §6.2 RPCRoster generation RPC with coverage predicate, deterministic package choice, advisory lock, paid-seat exclusionRoster follows package, safely8
M-B3 · spec M8lesson_waitlist with timed claimReleased seats get filled9
M-D1 · spec M2player_packages.status + past_due, paused; paused_from/untilSubscription states10
M-D2 · spec M1academy_settings: grace, freeze day, past-due day, renewal notice day, cancel rules, auto no-show hours, allow_overbook_reserved, proration_modeEvery rule is a setting with today's behaviour as default10
M-D3 · spec M16 + M17dispatch_roster_sync + dispatch_renewal_notices (pg_cron)Lapse removal and the day-26 notice10
M-E1 · spec M9payment_requests.bridge_tier, bridge_days; compute_bridgeThe bridge lands somewhere11
M-D4 · spec M10 + M14Checkout link per request, webhook handler, saved-card opt-in flag (off), charge attempt tableIssue on the 1st; opt-in charge later12
M-G1 · spec M15Reconciliation report tablePersistent payments ↔ POS view13
M-H1 · spec §9 viewAttendance-vs-payment viewPersistent Step 1 tab14
M-F1 · spec M13WhatsApp templates registry, opt-in/outCloud API16
M-I1 / M-I2 · spec M11leads, lead_events, conversion RPCTop of funnel17
M-J1 · spec M13message_templates keys; message logMessaging18
M-P1 · spec —Level historyPlan Step 419
M-M2 · spec M21bookings.source ('setpoint' | 'tps'), written by the OpenRally confirm RPCA card can say where it came from20
M-M1 · spec M20programs.source, programs.external_event_id (unique), program_type + americano, first_timeEvents are programs; a TPS event imports once and never echoes21
M-M3 · spec M22program_session_payload + instructor with a change guard in emit_program_event; inbound co-organiser → suggested coach, confirmed once (coach_external_links)Coach visible on both sides, no spoof, no event storm21
M-M4 · spec M23RLS: manager and staff INSERT/UPDATE on program_enrollments (enrollment columns only)Add-player on events from the desk; today owner/admin only22

API changes

  • New: GET /api/admin/academy-health (Step 1), POST /api/admin/lessons/[id]/attendance, POST /api/admin/academy-groups/[id]/generate, /slots, /api/admin/lesson-waitlist, /api/admin/subscriptions (list, pause, resume), /api/admin/academy-settings, /api/admin/reconciliation, /api/admin/export, /api/admin/audit, /api/admin/crm/leads, /api/admin/messages, /api/v1/me/lessons/[id]/cancel, GET /api/admin/calendar?coach= (coach filter), POST /api/admin/programs/[id]/enrollments (add player, waitlist when full), PATCH /api/admin/programs/[id] (instructor).
  • Changed: lessons PATCH accepts availability_override; lessons/recurring reads the reservation-aware balance; process-package-billing gains bridge, issue, past-due, pause.
  • Docs-sync on every PR: versions.ts, feature-matrix.ts, api-reference.ts, an ac-*.ts, a decisions.ts entry (from DEC-330).

Roles and autonomy

  • Setpoint roles: owner (Alvaro), admin (Jose), manager (Abby), coach. PR 3 adds requireRole so the twenty ad-hoc role constants agree; team routes get a gate.
  • AUTO: coverage, reservations, bridge amounts, reminders on approved templates, reconciliation matching, lapse flags.
  • STANDING RULE (Abby): issue requests from a list, send queued messages, attach payments, record cash, mark sent.
  • DECISION: Alvaro for any amount not on a list, pauses, write-offs; Jose for overrides, levels, groups, attendance corrections.
  • NEVER: charge a card without an opt-in, refund, delete, message an opt-out, move a court without the coach.

Plan in one glance

Twenty-two PRs in 03-plan.md, ordered read-only → infrastructure → ledger (flag off, then switched) → money → third-party gates, with the three operations PRs (20–22) independent of the ledger chain. Gates: Nestor's approval and review, Stripe live keys, Meta approval, Jose's decisions on slots, overrides and no-show, Alvaro's on cancel, grace and pause, event creation and add-player, one real cycle checked against Step 1, and the signed dry run.

By 16 Oct

Academy Health, the override defect, roles helper, audit log, CSV export, attendance state with the flag off, and the card contract with the coach filter in both apps (PR 20, read-only). Abby gets the worklist for the 1 Nov cycle; Jose starts marking attendance.

By 15 Nov

Ledger switched on 1 Nov after the signed dry run, groups linked to schedule, waitlist, subscription states, the bridge inside billing, events as programs with the coach on both sides and add-player from the phone (PRs 21–22). The 1 Nov request is still a payment link, but amount, state and roster come from Setpoint.

By 15 Dec

Requests issued by Setpoint on the 1st, persistent reconciliation and attendance-vs-payment, drag-and-drop planning, WhatsApp Cloud API. Leads, messaging and player progress follow.

Cut: push notifications, Stripe Subscriptions, off-session auto-charge without opt-in, makeup credits (fixed-slot rule), AI CRM, SEPA, white-label app.

Risks

RiskMitigation
ledger Over-reservation the day the flag flipsReservation-aware balance and the insert-time lock ship before the switch (PR 6/7 order), enforced by SQL evals.
ledger Backfill libels attendancePast lessons default to attended; no-show never inferred; Jose signs the report; switch reversible.
money Double issue on the hourly re-fireUnique request per subject + period already exists; any later charge uses an idempotency key and an attempt row.
money Past-due students vanish from billingDispatcher selects active or past due; function does not early-return for past due.
people Nightly job removes a player the coach planned on24 h cutoff, notice to coach and student, paid seats excluded.
third party Meta approval slipsManual lane stays; nothing else depends on it.
process Review load on NestorPR-sized steps, demo GIF each, read-only first; this package before any code.
events Two sources of truth for an event (TPS and Setpoint)One direction per event: programs.source decides who writes; a TPS-created event is read-only here until D19 switches creation to Setpoint; external_event_id is unique so an echo cannot import twice.
people "Unassigned" becomes a permanent stateIt is a worklist: events within 48 h with no coach appear on Academy Health and in the daily schedule message until cleared.
habit Coaches do not check inThe model rests on it. Measured for two weeks before auto no-show is enabled; the mobile "Done / No-show" is one tap.

Open decisions

Twenty-one, each with a recommended default, in 04-decisions.md: student-cancel rules, grace window, pause policy, how the 1st is charged, WhatsApp transport, who overrides rosters, who marks attendance, auto no-show, what players see, the Growth template, lead ownership, the switch date, push, export retention, audit scope, where the ledger flag lives, the second weekly slot, and from Feature M: where an event is created, who may add a player from the phone, the default coach filter.

Sources: 00-context.md (files read, confirmed facts, unconfirmed items) · _research-ledger-billing.md, _research-planning-groups.md, _research-crm-messaging-roles.md (file:line evidence) · _redteam-ledger.md (adversarial review) · _target-model.md. Nothing in ~/cowork/setpoint was modified; no migration was created; production was not touched. Mockups are static pages on Setpoint's own tokens and marked as such.