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.
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
- Monthly plans Essential (4 sessions, THB 4,500), Growth (6, THB 9,500), Performance (7, THB 14,500) become recurring subscriptions anchored to the 1st:
billing_anchor_day = 1,billing_mode = 'automatic',next_billing_dateset. Growth gets a new recurring template and its students are migrated; the existing template is never re-typed in place. - Credit packs (Starter, Pro, the 5/10/15 coaching packs, specials) stay session banks with expiry. Never prorated. Nothing due on the 1st.
- States on
player_packages.status: active past_due paused cancelled expired. Notrialing. Past due stays billable; paused re-anchors on resume; a paused month is not credited and the seat is released after the grace window (fixed-slot rule). - Anchor: the first cycle may be partial (the bridge); every later cycle runs from the 1st to the end of the month, facility-local.
Session ledger: reservation separated from consumption
| attendance_state | Meaning | Ledger effect | Who sets it |
|---|---|---|---|
| scheduled | On a future roster | Reserved. 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 |
| attended | Checked in or marked done | consume row, delta −1, metadata.attendance = 'attended' | Coach (PWA check-in), manager, or the auto rule when enabled |
| no_show | Fixed slot missed | consume 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_academy | Lesson cancelled or seat released by staff | Released; refund only if already consumed | Lesson cancel cascade, roster sync, staff |
| cancelled_by_student | Student cancelled | Group: 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
- A group gets schedule slots (weekday, time, duration, court, coach) and a class link. "Generate lessons" creates the month from the slots.
- Roster = every member whose package covers the lesson date. Coverage: recurring and active or past due with the date inside the period; or a session bank with no expiry or expiry on or after the date and sessions remaining. The package used is chosen deterministically and written on the seat.
- Uncovered members show as needs package and are excluded unless overridden with a reason code (comp, bill later, trial) that writes an audit row. Only owner and admin may override (D7).
- A lapsed package flags the member; after the grace window (7 days from the lapse) the nightly job removes them from future rosters, never from past attended lessons, never from a paid non-package seat, with a notice to coach and student.
Month-end billing
| Day | What happens | Exists today |
|---|---|---|
| 26 | Renewal notice for every active recurring package (own dispatcher, because reminder offsets are relative to the due date, not the 1st) | new |
| 1 | process-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 |
| 3 | Unpaid → past_due, reminder. Student keeps attending. | new state |
| 7 | Booking freeze: no new roster adds. Existing seats stay until grace ends (day 10). | new |
| Any | Bridge 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.
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, runsfindCourtConflicts, 409 on clash) andPATCH /api/admin/bookingsfor court bookings; the handler branches on card type because the bookings route refuses lesson legs. dnd-kitv6 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(editableFieldsomits 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.
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_groupsis roster + free-text level only, explicitly not linked to classes or lessons (migration header20260722032430). - New
academy_group_slots,academy_groups.class_id,lessons.academy_group_id; generation extendsapi/admin/lessons/recurringand 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_waitlistmodelled 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.
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 RPCmark_attendancewrites theconsumerow in the same transaction (DEC-319/320 pattern) with the flavour in metadata.academies.ledger_modeflag;on_addkeeps today's path;on_donemakescreate_lesson_atomicand 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_sessionsmust not zero next-period reservations. - Backfill and switch: §12 of the spec; inverse migration written and tested first.
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-billingalready rolls the cycle, writespayment_requests(unique per subject + period), queues reminders and advancesnext_billing_date; it grants no sessions and mints no link (AC-41 skipsstripe_subscription_idrows, which keep their invoice.paid path).- New: mint a Stripe Checkout/Payment Link per request (
checkout_url), webhookcheckout.session.completed→paymentsrow (the POS bridge trigger fires) → settle request → grant sessions;past_dueandpausedstates; dispatcher selects active or past due; resume re-anchors. - No off-session charge:
customers.stripe_customer_idexists 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.
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)inpackages/commonand mirrored in SQL;payment_requests.bridge_tier,bridge_days;proration_modesetting (none|weekly_quarters).- The current function writes
amount_amount = template.price_amountunconditionally; 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.
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-manualreturns the wa.me link and recordssent_manual;whatsapp-cloudthrows 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_remindersalready 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.
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_idandpayment_requests.checkout_id, joined to the POS mirror (pos_orderschannelacademy). - Partial payments: the POS bridge mirrors each
paymentsrow 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.
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.
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_customersis not in production (two migration headers record it), so leads link tousers/customersthroughcrm_customer_directorysemantics, not to that table. - New
leadsandlead_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 fromlesson_players/academy_group_members. - Push does not exist anywhere (the coach PWA is Flutter with no messaging); deferred.
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
csvCellhelper is importable. Streaming endpoint, 12-month cap, no stored files (D15). Coach hours = lesson duration, not a timesheet.
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_logsswallows errors by design and a decision entry rejects that for anything with stakes. Newadmin_audit_logwritten inside the same transaction by a helper; the reversal pattern onpayments.reverses_payment_idis the model. - Ships early (PR 4) because B, C, A and D depend on it.
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.dartoffers 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.dartalready loadscoaches(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_typeis already fetched).widgets/booking_detail_sheet.dartshows Coach only whencoach_nameis set and its_PlayersSectiononly for lesson-linked bookings; it gains the five-field header, an events roster fromprogram_enrollments, and an "Add player" sheet reusing the customer search fromcreate_booking_sheet.dart. Fix first:booking_detail_sheet.dartprintsnotesraw, 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.tsdefines Court Booking / Events / Coaching where "events" isguest_booking_category='event'; it gainsgroup(lessonclass_type='group') andevent(program occurrence) predicates, and a coach multiselect besidecalendar-category-filter(testidcalendar-coach-filter).calendar-grid.tsxalready carriescoach_nameandplayer_counton cards; it adds capacity, waitlist and source. Programs have no per-occurrence row: the mirror emits one session per program fromprogram_next_occurrence(earliest slot from today,20260616000100lines 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 keyprogram_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) andprogram_enrollments(enrolled / waitlisted / cancelled,waitlist_position) are already two-way with TPS through OpenRally:program_session_payloadmirrors out,confirm_program_enrollmentandcancel_session_enrollment_by_guesttake TPS joins and leaves, and admin enrolls emitsession_joined/session_leftto TPSevent_participants(migration20260617140000). New:programs.sourceandprograms.external_event_id(M-M1). Echo guard: the outbound mirror stampsexternal_event_idfrom 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_enrollmentsoncesource='setpoint', TPS oncesource='tps'; TPSdeleted=truemaps to a cancelled program, never a delete.americanoandfirst_timeadded toprogram_type. - Coach both ways.
program_session_payloadcarries no instructor today (zero mentions in20260616000100and20260616120000); M-M3 addsinstructor(id, name, email) to the payload and TPS maps it toevent_organizersby coach email, the same convention the daily schedule bot already uses. Inbound, a co-organiser email that matchescoachesonly suggests:instructor_idis 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_eventinserts intoor_eventswith no dedupe key (20260616000100lines 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 addsbookings.source('setpoint' | 'tps') written by the OpenRally confirm RPC so a court card can say where it came from. Payment state reads the existingpayment_status/payment_method; a player added at the desk ispay_at_venueuntil Abby records the payment. - Roles and tests. Add-player uses the existing coach and staff RLS on
lesson_players(20260721091646); for events,program_enrollmentsINSERT/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 (neverexternal_payment_reforhold_expires_at); coaches get no write on events and are limited to lessons wherelessons.coach_idis theirs. Playwright: one spec per surface withdata-testidon 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
| Screen | Today (captures) | After Feature M | PR |
|---|---|---|---|
| 1 · Calendar | Three 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 slot | Book 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 sheet | The "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 player | Does 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 sheet | The "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 · Filters | Courts · 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 first | The "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 id | Change | Why | PR |
|---|---|---|---|
| M-L1 · spec M12 | admin_audit_log + transactional helper | Every write feature below needs an actor, before/after and a reason | 4 |
| M-C1 · spec M3 | lesson_players.attendance_state, attendance_marked_at/by, override_reason, override_reason_code, override_by | The state the whole model reads | 6 |
| M-C2 · spec M1 + M4 | academies.ledger_mode; reservation-aware get_player_package_remaining_sessions | Flag defaults to today; the series cap stops over-booking the day the flag flips | 6 |
| M-C3 · spec M5 | mark_attendance RPC; consume metadata; ledger_mode_switch reason + inverse; done-time exhaustion | Consumption at done time, one reason, reversible switch | 7 |
| M-C4 · spec M18 + M19 | Dry-run report function + one-transaction switch keyed to the snapshot | Jose signs a report, the commit refuses rows that moved | 7 |
| M-C5 · spec §12 script | Growth (monthly) recurring template; migration script for the 9 multi-package students and 62 NULL-expiry packages | Never re-type a template in place | 7 |
| M-B1 · spec M6 + M7 | academy_group_slots; academy_groups.class_id; lessons.academy_group_id | Groups get a schedule | 8 |
| M-B2 · spec §6.2 RPC | Roster generation RPC with coverage predicate, deterministic package choice, advisory lock, paid-seat exclusion | Roster follows package, safely | 8 |
| M-B3 · spec M8 | lesson_waitlist with timed claim | Released seats get filled | 9 |
| M-D1 · spec M2 | player_packages.status + past_due, paused; paused_from/until | Subscription states | 10 |
| M-D2 · spec M1 | academy_settings: grace, freeze day, past-due day, renewal notice day, cancel rules, auto no-show hours, allow_overbook_reserved, proration_mode | Every rule is a setting with today's behaviour as default | 10 |
| M-D3 · spec M16 + M17 | dispatch_roster_sync + dispatch_renewal_notices (pg_cron) | Lapse removal and the day-26 notice | 10 |
| M-E1 · spec M9 | payment_requests.bridge_tier, bridge_days; compute_bridge | The bridge lands somewhere | 11 |
| M-D4 · spec M10 + M14 | Checkout link per request, webhook handler, saved-card opt-in flag (off), charge attempt table | Issue on the 1st; opt-in charge later | 12 |
| M-G1 · spec M15 | Reconciliation report table | Persistent payments ↔ POS view | 13 |
| M-H1 · spec §9 view | Attendance-vs-payment view | Persistent Step 1 tab | 14 |
| M-F1 · spec M13 | WhatsApp templates registry, opt-in/out | Cloud API | 16 |
| M-I1 / M-I2 · spec M11 | leads, lead_events, conversion RPC | Top of funnel | 17 |
| M-J1 · spec M13 | message_templates keys; message log | Messaging | 18 |
| M-P1 · spec — | Level history | Plan Step 4 | 19 |
| M-M2 · spec M21 | bookings.source ('setpoint' | 'tps'), written by the OpenRally confirm RPC | A card can say where it came from | 20 |
| M-M1 · spec M20 | programs.source, programs.external_event_id (unique), program_type + americano, first_time | Events are programs; a TPS event imports once and never echoes | 21 |
| M-M3 · spec M22 | program_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 storm | 21 |
| M-M4 · spec M23 | RLS: manager and staff INSERT/UPDATE on program_enrollments (enrollment columns only) | Add-player on events from the desk; today owner/admin only | 22 |
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/recurringreads the reservation-aware balance;process-package-billinggains bridge, issue, past-due, pause. - Docs-sync on every PR:
versions.ts,feature-matrix.ts,api-reference.ts, anac-*.ts, adecisions.tsentry (from DEC-330).
Roles and autonomy
- Setpoint roles: owner (Alvaro), admin (Jose), manager (Abby), coach. PR 3 adds
requireRoleso 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
| Risk | Mitigation |
|---|---|
| ledger Over-reservation the day the flag flips | Reservation-aware balance and the insert-time lock ship before the switch (PR 6/7 order), enforced by SQL evals. |
| ledger Backfill libels attendance | Past lessons default to attended; no-show never inferred; Jose signs the report; switch reversible. |
| money Double issue on the hourly re-fire | Unique request per subject + period already exists; any later charge uses an idempotency key and an attempt row. |
| money Past-due students vanish from billing | Dispatcher selects active or past due; function does not early-return for past due. |
| people Nightly job removes a player the coach planned on | 24 h cutoff, notice to coach and student, paid seats excluded. |
| third party Meta approval slips | Manual lane stays; nothing else depends on it. |
| process Review load on Nestor | PR-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 state | It 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 in | The 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.