02 · Technical specification
Written 16 September 2026. Companion to _target-model.md (intent), _research-ledger-billing.md / _research-planning-groups.md / _research-crm-messaging-roles.md (repo facts, every claim file:line), 04-decisions.md (D1–D16, defaults assumed), _redteam-ledger.md (adversarial review of the ledger and billing model), PHASE-1-SPEC.md (Step 1 Academy Health, already specified, not repeated here).
Repo under specification: /Users/Alvaro/cowork/setpoint. Nothing in it was modified to write this document.
1. Scope and principles
1.1 Feature letters
The letters used by 04-decisions.md. A is specified already in PHASE-1-SPEC.md and is referenced, not redesigned.
| Letter | Feature | Spec sections |
|---|---|---|
| A | Academy Health (read-only worklist, tc-284) |
9 (persistent successor only) |
| B | Groups with a schedule, roster follows package | 2, 6 |
| C | Attendance state and the session ledger | 2, 3, 12 |
| D | Subscription lifecycle: pause, past due, freeze, month-end cycle | 2, 4 |
| E | Plans and packages: Growth re-platforming, bridge pricing | 2, 4, 12 |
| F | Messaging: WhatsApp Cloud API, template keys | 2, 8 |
| G | Card collection on the 1st: mandate, charge, dunning | 2, 4, 5 |
| H | Planning drag-and-drop, waitlist | 2, 7 |
| I | Leads and conversion | 2 |
| J | What a player sees | 8, 10 |
| K | Exports | 10 |
| L | Audit log and role gates | 2, 11 |
1.2 Principles
- Extend, never restyle. Every route, RPC, trigger and component named below already exists unless marked new. No file is rewritten to a different pattern because a newer one is nicer.
- Consumer-agnostic. No table, column, setting, template key or test fixture names Pad Thai Padel or The Padel Society. Club-specific facts live in data, per
ac-academy-payments.tsAC-50. - Every money or session mutation is transactional and ledgered. Balance changes are written inside the plpgsql RPC that mutates the counter, in the same transaction (DEC-320). Admin actions write
admin_audit_login the same transaction, never fire-and-forget —decisions.ts:180rejects thepos_activity_logsswallow-errors pattern explicitly for anything with stakes (_research-crm-messaging-roles.md§4). - Flags default to today's behaviour. Every new setting ships with the default that reproduces current production behaviour, following DEC-327 ("a migration must never change what a running business charges people") and
academy_payment_settings.enabled = false. - Billing and the ledger ship as separate releases. Red-team §6.9. The ledger switch (C) is release n; card collection (G) is release n+1. They share no migration.
- Reservations are a locked fact on a row, not a computed count and not a ledger row. See section 3 and the red-team responses there.
1.3 Red-team findings answered in this document
| Finding | Severity | Answered in |
|---|---|---|
No lock left to take under on_done |
FATAL | 3.2 |
get_player_package_remaining_sessions over-books the series cap |
FATAL | 2 M4, 3.6 |
| A roster seat has no package id | FATAL | 3.2, 6.2 |
release as a delta-0 ledger row breaks DEC-319/320 |
FATAL | 3.3 |
| Two new consume reasons split every reader | FATAL | 3.3 |
Compensating refund rows NULL lessons.player_package_id |
FATAL | 12.3 |
no_show inference libels real attendance |
FATAL | 12.4 |
| No saved payment method exists anywhere | FATAL | 5.2 |
| Most Thai academy money is not chargeable off-session | FATAL | 4.6 |
| Pay-per-seat joins bypass packages | HIGH | 3.5 |
| Holds vanish on release | HIGH | 3.5 |
| Private lessons consume at lesson level | HIGH | 3.5 |
balance_after ordering outside the bucket lock |
HIGH | 3.3 |
refund is the wrong reason for the switch |
HIGH | 12.3 |
| Hourly re-fire becomes a double charge | HIGH | 4.4 |
SCA/3DS turns technical failure into past_due |
HIGH | 4.5 |
| The bridge has nowhere to land | HIGH | 2 M9, 4.3 |
past_due stops billing entirely |
HIGH | 4.5 |
| Re-typing Growth changes existing students | HIGH | 12.5 |
62 packages with NULL expires_at fall off rosters |
HIGH | 6.2, 12.6 |
| 9 students with >1 active package have no tie-break | HIGH | 6.2 |
| Never evict a paid seat | HIGH | 6.4 |
| A 03:00 job rewrites the coach's roster | HIGH | 6.4 |
Medium and low findings are answered inline where the design touches them, each marked RT.
2. Schema deltas
Migration files are authored with supabase migration new; the timestamps below are placeholders of the form YYYYMMDDHHMMSS_<slug>.sql. No applied migration is ever edited — every change is additive and forward-only. packages/database/src/database.types.ts is regenerated once per PR.
Id mapping. 03-plan.md and 01-scope.html name migrations per feature; this spec numbers them in build order. Both refer to the same files:
| Plan / scope id | This spec | Notes |
|---|---|---|
| M-L1 | M12 admin_audit_log |
PR 4 |
| M-C1 | M3 lesson_players_attendance_state |
PR 6 |
| M-C2 | M1 academy_settings (+ academies.ledger_mode) and M4 package_session_reservations |
PR 6: flag defaults to today; reservation-aware balance and insert-time lock |
| M-C3 | M5 ledger_mode_switch_reason + mark_attendance RPC (§3) |
PR 7 |
| M-C4 | M18 ledger_mode_switch + M19 ledger_mode_switch_inverse |
PR 7, data migrations (§12) |
| M-C5 | §12 data script: Growth (monthly) template, multi-package and NULL-expiry resolution | PR 7, no schema change |
| M-B1 | M6 academy_group_schedule + M7 lessons_academy_group_id |
PR 8 |
| M-B2 | roster generation RPC (§6.2), rides on M4/M6 | PR 8 |
| M-B3 | M8 lesson_waitlist |
PR 9 |
| M-D1 | M2 player_packages_pause_past_due |
PR 10 |
| M-D2 | M1 academy_settings |
PR 10 (same file as M-C2's settings; the flag ships first) |
| M-D3 | M16 dispatch_roster_sync + M17 dispatch_renewal_notices |
PR 10 |
| M-D4 | M10 package_charge_attempts + M14 customer_payment_mandates |
PR 12; mandates table only used by the later opt-in |
| M-E1 | M9 payment_requests_bridge |
PR 11 |
| M-F1 | M13 message_templates_academy_keys + opt-out stop-gap (8.2) |
PR 16 |
| M-G1 | M15 academy_reconciliation_reports |
PR 13 |
| M-H1 | attendance-vs-payment view (§9) | PR 14 |
| M-I1 / M-I2 | M11 academy_leads (leads + lead_events) |
PR 17 |
| M-J1 | M13 message_templates_academy_keys + message log |
PR 18 |
| M-P1 | level history | PR 19, outside this spec (Plan Step 4) |
| M-M2 | M21 bookings_source |
PR 20 |
| M-M1 | M20 programs_source_external_event (+ program_type values) |
PR 21 |
| M-M3 | M22 program_session_payload_instructor (function change, no table) |
PR 21 |
| # | File slug | Table | Column / constraint | Type | Default | Why |
|---|---|---|---|---|---|---|
| M1 | academy_settings |
academy_settings (new) |
academy_id PK |
uuid | — | FK academies(id) CASCADE. Keyed on the academy, not the facility: Pad Thai Padel runs several sites and a student training at two would otherwise get half their seats on each rule (RT §3.4). academy_payment_settings stays facility-keyed and keeps reminder transport. Follows settings pattern A (_research-crm-messaging-roles.md §7). |
academies |
ledger_mode |
text | 'on_add' |
CHECK ('on_add','on_done'). D17 puts the flag on academies, not the facility: a facility flag splits a student who trains at two of the same academy's sites. Default reproduces today. Read through a helper academy_ledger_mode(p_facility_id, p_academy_id) so no call site has to join. |
||
auto_mark_no_show_hours |
smallint | NULL |
NULL = manual only, which is where this academy starts. D9 turns it to 12 only once check-in is a habit — more than 80 % of seats checked in for two consecutive weeks, measured by the nightly report. CHECK > 0. |
|||
student_cancel_rule_group |
text | 'deduct' |
CHECK ('deduct','follow_cancellation_policy'). D1 default (a). Not an hours value — the hours stay on cancellation_policies.free_cancellation_hours so a club cannot hold two different deadlines (RT §1.8). |
|||
student_cancel_rule_individual |
text | 'follow_cancellation_policy' |
same CHECK. D2 default (b). | |||
roster_grace_days |
smallint | 7 |
CHECK 0..60. D3: counted from the lapse day, so past_due on day 3 means removal on day 10 — after the day-7 freeze, so a student never loses adds and existing seats on the same day (RT §5.5). See 6.4. |
|||
pause_notice_day |
smallint | 26 |
CHECK 1..28. | |||
past_due_day |
smallint | 3 |
CHECK 1..28. | |||
freeze_day |
smallint | 7 |
CHECK 1..28, >= past_due_day. |
|||
reminder_day_before_1st |
smallint | 26 |
CHECK 1..28. Dispatched by its own job, not a reminder_offset (RT §4.8). |
|||
allow_overbook_reserved |
boolean | false |
Enforced by M4's lock, not by a report. | |||
proration_mode |
text | 'none' |
CHECK ('none','weekly_quarters'). none reproduces today. |
|||
slot_allocation |
text | 'spread' |
CHECK ('date_order','spread'). D18: a four-session plan in a two-slot group gets four seats spread across the month, and the second weekly slot is sold as a priced add-on rather than implied (RT §5.3). See 6.3. |
|||
updated_by, updated_at |
uuid, timestamptz | NULL, now() |
FK auth.users SET NULL, per the house pattern. |
|||
| M2 | player_packages_pause_past_due |
player_packages |
status CHECK |
text | — | DROP + ADD CHECK ('active','exhausted','expired','cancelled','past_due','paused'). Additive: no existing row changes value. |
paused_from, paused_until |
date | NULL | Both NULL or both set (CHECK). paused_until is exclusive and always a 1st. |
|||
pause_requested_at, pause_requested_by |
timestamptz, uuid | NULL | Proof the notice landed before pause_notice_day (D4). |
|||
| M3 | lesson_players_attendance_state |
lesson_players |
attendance_state |
text | NULL |
CHECK attendance_state IS NULL OR attendance_state IN ('scheduled','attended','no_show','cancelled_by_academy','cancelled_by_student'). Nullable so 698 historical seats stay valid without a data pass; NULL means "pre-switch seat, read checked_in_at/attended". |
attendance_marked_at, attendance_marked_by |
timestamptz, uuid | NULL | FK auth.users SET NULL. |
|||
override_kind |
text | NULL | CHECK ('comp','makeup','trial','goodwill','billing_pending'). A free-text reason alone leaves a favour invisible to money (RT §5.4); the code says how it is settled. |
|||
override_reason |
text | NULL | CHECK override_kind IS NULL OR btrim(override_reason) <> ''. |
|||
override_by |
uuid | NULL | FK auth.users SET NULL. Written only by owner/admin per D7. |
|||
player_package_session_id |
uuid | NULL | FK player_package_sessions SET NULL. Pins the bucket at reservation time so the same seat can never attribute to a different bucket on a later render (RT §3.5). |
|||
coverage_state |
text | NULL |
CHECK (NULL,'covered','pending_period','uncovered'). pending_period = the lesson falls in a future billing period whose grant does not exist yet (RT §2.5). |
|||
| index | — | — | idx_lesson_players_package_state ON lesson_players (player_package_id, attendance_state) WHERE player_package_id IS NOT NULL. |
|||
| M4 | package_session_reservations |
player_package_sessions |
sessions_reserved |
integer | 0 |
CHECK >= 0. The reservation counter. Mutated only inside the same SELECT … FOR UPDATE on the bucket that consume_package_session already takes (20260824200640_package_session_ledger.sql:259), which is the lock the red team found missing (RT §1.1). |
| RPC | reserve_package_session (new) |
— | — | See 3.4. | ||
| RPC | release_package_reservation (new) |
— | — | See 3.4. | ||
| RPC | mark_attendance (new) |
— | — | See 3.4. | ||
| fn | get_player_package_remaining_sessions |
— | — | Redefined (20260803101048_get_player_package_remaining_sessions.sql) to return SUM(sessions_remaining - sessions_reserved). Without this the recurring generator at apps/admin/src/app/api/admin/lessons/recurring/route.ts:181 enrols a 4-session student in 13 occurrences the day the flag flips (RT §1.2). |
||
| trigger | check_package_exhaustion |
— | — | Trigger widened from AFTER UPDATE OF sessions_remaining (00000000000000_initial_schema.sql:7892) to AFTER UPDATE OF sessions_remaining, sessions_reserved, comparing effective remaining (RT §1.7). |
||
| M5 | ledger_mode_switch_reason |
package_session_transactions |
reason CHECK |
text | — | DROP + ADD with one new value: ledger_mode_switch. no_show_consume, student_cancel_consume and release are rejected — see 3.3. |
| M6 | academy_group_schedule |
academy_groups |
class_id |
uuid | NULL | FK classes SET NULL. The template a generated lesson is created from. |
academy_group_slots (new) |
id, group_id, weekday smallint, start_time time, duration_minutes int, court_id uuid, coach_id uuid, is_active bool, created_at |
— | — | UNIQUE (group_id, weekday, start_time). weekday CHECK 0..6 (0 = Sunday, matching @setpoint/common/recurrence). coach_id NULL = inherit academy_groups.coach_id; the slot wins when set, so the two never drift (RT §5.6). |
||
| M7 | lessons_academy_group_id |
lessons |
academy_group_id |
uuid | NULL | FK academy_groups SET NULL, indexed. The only link between a group and a lesson; 20260722032430_add_academy_groups.sql states none exists today. |
academy_group_slot_id |
uuid | NULL | FK academy_group_slots SET NULL. Lets the sync job recognise its own output. |
|||
| M8 | lesson_waitlist |
lesson_waitlist (new) |
id, facility_id, lesson_id, user_id, player_package_id, position int, status text, claim_expires_at, promoted_lesson_player_id, created_at |
— | — | status CHECK ('waiting','claim_offered','claimed','expired','cancelled'), copying the four-value shape of program_enrollments (20260605094626:79-82). Position by read-max-plus-one, the pattern at _research-planning-groups.md §5. UNIQUE (lesson_id, user_id) WHERE status IN ('waiting','claim_offered'). |
| M9 | payment_requests_bridge |
payment_requests |
bridge_tier |
text | NULL | CHECK (NULL,'PRORATE75','PRORATE50','PRORATE25','full','wait'). The bridge currently has nowhere to land (RT §4.5) — process-package-billing writes amount_amount: template.price_amount unconditionally. |
bridge_days |
integer | NULL | CHECK >= 0. Days from the period start to the next 1st, as computed. |
|||
CHECK (bridge_tier IS NULL) = (bridge_days IS NULL), mirroring the existing period_start/period_end pair CHECK. |
||||||
| M10 | package_charge_attempts |
package_charge_attempts (new) |
id, facility_id, player_package_id, payment_request_id, period_start date, stripe_idempotency_key text, stripe_payment_intent_id, status text, failure_code, failure_message, attempt smallint, next_retry_at, created_at, updated_at |
— | — | UNIQUE (player_package_id, period_start, attempt); UNIQUE (stripe_idempotency_key). status CHECK ('pending','processing','succeeded','authentication_required','failed','abandoned'). Consulted before the Stripe call, so an hourly re-fire cannot double-charge (RT §4.3). |
| M11 | academy_leads |
leads (new) |
id, facility_id NOT NULL, academy_id, name, email, phone, phone_country_code, source text, source_detail, status text, owner_id, converted_customer_id uuid, converted_at, notes, metadata jsonb, created_by, created_at, updated_at |
— | — | source CHECK ('walk_in','website','instagram','whatsapp','referral','event','import','other'). status CHECK ('new','contacted','trial_booked','won','lost','duplicate'). owner_id FK auth.users SET NULL (D12: one owner). converted_customer_id FK customers(id) — a lead converts into the existing identity chain auth.users → customers; it must not reference facility_customers, which two migration headers confirm is not deployed to production (_research-crm-messaging-roles.md §8). Conversion calls the existing resolveOrCreatePlayerAccount (apps/admin/src/lib/find-or-create-user.ts) and then writes a crm_customer_directory row so the new player is visible in production's crm_search_customers. Partial UNIQUE on (facility_id, lower(email)) WHERE converted_customer_id IS NULL AND status <> 'lost'. |
lead_events (new) |
id, lead_id, facility_id, event_type text, actor_id, body text, metadata jsonb, created_at |
— | — | event_type CHECK ('created','contacted','note','status_changed','message_sent','converted','lost'). Append-only; no UPDATE/DELETE policy. |
||
| M12 | admin_audit_log |
admin_audit_log (new) |
id uuid PK, facility_id uuid NOT NULL, actor_id uuid, action text NOT NULL, entity_type text NOT NULL, entity_id uuid, description text NOT NULL, before jsonb, after jsonb, metadata jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT clock_timestamp() |
— | — | Column shape copied from pos_activity_logs (00000000000000_initial_schema.sql:4165) plus before/after; the writer is not copied (decisions.ts:180). actor_id FK auth.users SET NULL so deleting a customer anonymises rather than destroys the log, which 20260706041805_delete_customer_record.sql depends on. clock_timestamp() not now(), so several rows in one transaction stay orderable (the ledger's own reasoning, 20260824200640:39-44). Index (facility_id, created_at DESC), (entity_type, entity_id, created_at DESC). |
| M13 | message_templates_academy_keys |
message_templates |
seed rows | — | — | New system-default keys (facility_id NULL), each in both email and whatsapp: package_renewal_notice, package_past_due, package_frozen, package_paused_confirm, roster_seat_released, lesson_rescheduled, waitlist_seat_offered, card_authentication_required. Inserted through the partial unique index message_templates_system_default_key. Merge tags limited to the registered set in packages/common/src/merge-tags.ts plus three new ones registered there: lesson_date, group_name, claim_deadline. |
| M14 | customer_payment_mandates |
customer_payment_mandates (new) |
id, facility_id NOT NULL, customer_id uuid NOT NULL, stripe_customer_id text NOT NULL, stripe_payment_method_id text NOT NULL, setup_intent_id text, brand, last4, exp_month, exp_year, status text, consent_at timestamptz NOT NULL, consent_source text, revoked_at, created_at |
— | — | status CHECK ('active','revoked','expired','failed'). Partial UNIQUE (facility_id, customer_id) WHERE status = 'active'. Why a new table and not a column: customers.stripe_customer_id already exists and is already written by apps/admin/src/app/api/v1/_lib/stripe-customer.ts:18-43, so the customer id needs no new home; what does not exist anywhere is a stored mandate — a repo-wide grep for setup_intent, off_session and save_payment_method returns nothing (RT §4.1). A mandate is per-facility consent with a timestamp and a revocation, which is a row, not a column. customers.default_payment_method_id is global and its semantics are UNVERIFIED; it is not used. |
| M15 | academy_reconciliation_reports |
academy_reconciliation_reports (new) |
id, facility_id, report_date date, generated_at, rows jsonb NOT NULL, summary jsonb NOT NULL |
— | — | UNIQUE (facility_id, report_date). The persistent successor of Step 1's three tabs; see section 9. |
| M16 | dispatch_roster_sync |
pg_cron | — | — | — | cron.schedule('dispatch-roster-sync','0 2 * * *', …), one daily run, same net.http_post + Vault secret shape as dispatch_package_billing (20260824200650:100-145, DEC-326). |
| M17 | dispatch_renewal_notices |
pg_cron | — | — | — | cron.schedule('dispatch-renewal-notices','0 * * * *', …); hourly so send_hour_local lands correctly per facility timezone, firing only on reminder_day_before_1st. A -5 offset on reminder_offsets lands on the 3rd, not the 26th (RT §4.8). |
| M18 | ledger_mode_switch |
data migration | — | — | — | The on_add → on_done reconciliation. Section 12. |
| M19 | ledger_mode_switch_inverse |
data migration | — | — | — | Written and tested before M18 runs (RT §3.7). Section 12.8. |
RLS on every new table follows the house rule (_research-crm-messaging-roles.md §4): facility_id NOT NULL, staff SELECT via public.user_facility_role(facility_id), INSERT/UPDATE restricted to service_role, no DELETE policy on the append-only tables (admin_audit_log, lead_events, package_charge_attempts).
Nothing else is added. In particular: no users table (there is none — the profile table is customers), no stripe_customers table, no column on facility_customers, no new lesson_players.status value.
3. Ledger state machine
3.1 States
stateDiagram-v2
[*] --> scheduled : reserve_package_session()
scheduled --> attended : mark_attendance('attended')
scheduled --> no_show : mark_attendance('no_show') / auto job
scheduled --> cancelled_by_academy : lesson cancel · roster sync · staff remove
scheduled --> cancelled_by_student : student or staff on their behalf
attended --> scheduled : mark_attendance reversal (< 48h, owner/admin)
no_show --> attended : mark_attendance correction (< 48h, D8)
cancelled_by_student --> scheduled : staff reinstate (< 48h)
attended --> [*]
no_show --> [*]
cancelled_by_academy --> [*]
cancelled_by_student --> [*]
scheduled holds a reservation. attended, no_show and a deducting cancelled_by_student hold a consumption. cancelled_by_academy and a releasing cancelled_by_student hold neither.
3.2 Reservation is a counter under a lock — red-team finding → design response
Finding (FATAL, RT §1.1/§1.3): under on_done, "reserved" becomes a COUNT over lesson_players, which is a read with no row to lock; and a generated roster seat carries no player_package_id at all, so the count is uncomputable for exactly the seats the model is about.
Response. Reserved is not counted. It is player_package_sessions.sessions_reserved (M4), incremented inside the same SELECT … FOR UPDATE OF pps that consume_package_session already takes at 20260824200640_package_session_ledger.sql:259. Every roster insert for a package seat goes through reserve_package_session, which in one statement: picks the bucket by the existing exact-class_id → class_type → 'any' priority (:245-259), locks it, refuses when sessions_remaining - sessions_reserved <= 0 unless allow_overbook_reserved, increments sessions_reserved, and writes lesson_players.player_package_id, player_package_session_id and attendance_state='scheduled'. Two staff adding the same student in the same second serialise on the bucket row; the second gets package_no_sessions_remaining, the existing error. allow_overbook_reserved is therefore enforced by a constraint path, not a report.
3.3 One consume reason, no release row — red-team finding → design response
Finding (FATAL, RT §2.1/§2.2, HIGH §2.3): a delta-0 release row mutates no counter and belongs to no bucket, breaking DEC-319/320 and possibly recording a balance_after that was never true; and adding no_show_consume / student_cancel_consume silently breaks every reader that filters reason='consume' (the literal union is mirrored in apps/pos/src/types/database.ts, apps/terminal/src/types/database.ts and academy-payments-types.ts).
Response. The ledger records balance changes only.
- No
releasereason. A released reservation decrementssessions_reservedunder the bucket lock and writes oneadmin_audit_logrow (action='reservation.released', entitylesson_player). The audit trail lives in the audit table, which is where a non-balance fact belongs. - One
consumereason. The flavour goes inmetadata:{"attendance":"attended"|"no_show"|"student_cancel","lesson_player_id":…,"rule":"group_deduct"|"policy_deduct"}._target-model.md§2 already put no-show inmetadata; this makes it the single mechanism. Existing readers keep working unchanged; utilisation reports gain ametadata->>'attendance'breakdown in the same PR. - The CHECK gains exactly one value,
ledger_mode_switch(M5), used only by the one-time switch (12.3).
3.4 Transitions, ledger rows, and the RPC that writes them
c = consume_package_session (extended to accept p_metadata jsonb and p_lesson_player_id), r = refund_package_session, res = reserve_package_session (new), rel = release_package_reservation (new). All are SECURITY DEFINER, REVOKE ALL FROM PUBLIC, granted to service_role only, matching adjust_package_sessions (20260824200640:610-612).
| Transition | Counter effect | Ledger row | RPC |
|---|---|---|---|
roster add (auto or manual), ledger_mode='on_done' |
sessions_reserved +1 |
none | res |
roster add, ledger_mode='on_add' (today) |
sessions_remaining -1, sessions_used +1 |
consume, delta −1, source='live' |
c unchanged |
scheduled → attended |
sessions_reserved -1, sessions_remaining -1, sessions_used +1 |
consume, delta −1, metadata.attendance='attended' |
mark_attendance → c |
scheduled → no_show |
same as attended | consume, delta −1, metadata.attendance='no_show' |
mark_attendance → c |
scheduled → cancelled_by_student, group, rule deduct (D1) |
same as attended | consume, delta −1, metadata.attendance='student_cancel', metadata.rule='group_deduct' |
mark_attendance → c |
scheduled → cancelled_by_student, outside free_cancellation_hours |
same as attended | consume, delta −1, metadata.rule='policy_deduct' |
mark_attendance → c |
scheduled → cancelled_by_student, inside the free window (D2) |
sessions_reserved -1 |
none | mark_attendance → rel |
scheduled → cancelled_by_academy (lesson cancel, roster sync, staff remove) |
sessions_reserved -1 |
none | rel |
attended → scheduled / no_show → scheduled reversal within 48h |
sessions_remaining +1, sessions_used -1, sessions_reserved +1 |
refund, delta +1, note mandatory |
mark_attendance → r then res |
already-consumed seat on a cancelled lesson (on_add rows) |
sessions_remaining +1 |
refund, delta +1 |
r unchanged, via cancel-lesson-cascade.ts |
mark_attendance(p_lesson_player_id uuid, p_state text, p_actor uuid, p_reason text DEFAULT NULL) RETURNS json:
- Lock the
lesson_playersrowFOR UPDATE, join itslessonsrow. - Refuse (
P0001 attendance_not_a_package_seat) when the seat is not a package seat — see 3.5. - Refuse when the requested state equals the current state (idempotent no-op returns
{changed:false}), and when moving out of a terminal state more than 48 hours afterattendance_marked_at(D8: the manager corrects within 48 h with a reason;p_reasonis mandatory for any correction, raisingP0001if blank, exactly likeadjust_package_sessions). - Lock the pinned bucket via
player_package_session_id, apply the counter effects and the ledger row in the same transaction. - Write
attendance_state,attendance_marked_at = now(),attendance_marked_by = p_actor, and oneadmin_audit_logrow withbefore/after.
Exhaustion moves to done time. check_package_exhaustion is widened to fire on sessions_reserved too (M4), so a fully reserved package flips to exhausted even though sessions_remaining has not moved. Without that, every status='active' gate in the app claims sessions are available when all four are spoken for (RT §1.7).
3.5 Which seats the ledger owns — red-team findings → design response
Findings (HIGH, RT §1.4/§1.5/§1.6, LOW §1.9). Pay-per-seat joins (confirm_group_lesson_seat, confirm_group_lesson_seat_from_hold, 20260618000000:308,350) set paid_amount and payment_method='stripe' and never touch a package. release_group_lesson_hold DELETEs the row. Private lessons consume against lessons.player_package_id, not a player row. Credits debit at add time via record_credit_transaction.
Response. One predicate, is_package_seat(lp), used by reserve_package_session, mark_attendance, dispatch_roster_sync and every report:
lp.payment_method = 'package'
AND lp.player_package_id IS NOT NULL
AND COALESCE(lp.paid_amount, 0) = 0
AND lp.payment_intent_id IS NULL
AND lp.external_payment_ref IS NULL
- A seat that fails the predicate is never reserved, never consumed and never evicted. A student who paid cash for a drop-in is not charged a session at check-in.
release_group_lesson_holdgains one line before its DELETE: callrelease_package_reservationwhen the row holds one, and write theadmin_audit_logrow. The audit of a released hold survives the delete.- Private lessons. Under
on_donethe package attribution for a private lesson moves to the primarylesson_playersrow, whichcreate_lesson_atomicalready inserts;lessons.player_package_idcontinues to be written for back-compatibility but is no longer the consumption trigger. When a private lesson has no player row carrying the package,create_lesson_atomicraises rather than creating an unattributable seat. - Credits are out of scope and stay on deduct-at-add. Stated, not silently mixed.
3.6 Reserved beyond the current period
A seat may be reserved against a recurring package only when the lesson's facility-local start date falls inside current_period_start .. current_period_end; against a session bank only when expires_at IS NULL OR lesson_date <= expires_at. Seats on lessons beyond that window are created with player_package_id = NULL and coverage_state = 'pending_period', and dispatch_roster_sync attaches the package on the renewal date. This removes the ambiguity the red team raised about which period a 2 October lesson booked on 30 September consumes (RT §2.5) and keeps "October's four sessions" true.
4. Billing flow
4.1 Sequence
sequenceDiagram
participant Cron as pg_cron
participant PPB as process-package-billing
participant Stripe
participant DB as Postgres
participant POS as POS bridge trigger
participant Msg as send-payment-reminder
Note over Cron,Msg: Day 26 — renewal notice
Cron->>Msg: dispatch-renewal-notices (hourly, fires at send_hour_local on day 26)
Msg-->>DB: payment_reminders row, template package_renewal_notice
Note over Cron,POS: Day 1 — raise and charge
Cron->>PPB: dispatch_package_billing (hourly, status IN active/past_due)
PPB->>DB: INSERT payment_requests (23505 = already raised, continue)
PPB->>DB: compute_bridge() -> bridge_tier, bridge_days, amount_amount
PPB->>DB: INSERT package_charge_attempts (status pending, idempotency key)
alt default lane (D5c) — every student
PPB->>Stripe: Checkout Session (card + PromptPay) -> checkout_id, checkout_url
PPB->>DB: queue reminders from reminder_offsets carrying the link
else opted-in mandate lane (D5b, later release)
PPB->>Stripe: PaymentIntent confirm=true off_session=true (Idempotency-Key)
Stripe-->>PPB: succeeded | authentication_required | card_error
end
Stripe-->>PPB: webhook payment_intent.succeeded
PPB->>DB: settle-payment-request: CAS status -> paid, reset_package_sessions
PPB->>DB: INSERT payments (type package, method stripe)
DB->>POS: record_academy_payment_in_pos_trigger (same transaction)
Note over Cron,DB: Day 3 — past due
Cron->>DB: unpaid and no attempt in authentication_required -> status past_due
Note over Cron,DB: Day 7 — freeze
Cron->>DB: freeze: no new roster adds; existing seats stay
Note over Cron,DB: Day 10 — roster removal (lapse day + roster_grace_days)
4.2 What does not change
process-package-billing keeps its three skip gates, its 422 on a NULL billing_period, its local-midnight period arithmetic, its "advance the cycle last" ordering and its rule that sessions are granted only when a request is marked paid (DEC-325). Packages carrying stripe_subscription_id keep their existing invoice.paid → reset_package_sessions path and are never migrated silently (AC-41, RT §4.10).
4.3 compute_bridge
A pure SQL function so the same number appears in Step 1's proposal tab and in the charge.
compute_bridge(p_price integer, p_days_to_next_1st integer)
RETURNS TABLE (amount integer, tier text, days integer)
-- weeks := LEAST(FLOOR(p_days_to_next_1st / 7), 4)
-- p_days_to_next_1st <= 7 -> (0, 'wait', p_days)
-- weeks >= 4 -> (p_price, 'full', p_days)
-- weeks = 3 -> (round(p_price * 3 / 4.0), 'PRORATE75', p_days)
-- weeks = 2 -> (round(p_price * 2 / 4.0), 'PRORATE50', p_days)
-- weeks = 1 -> (round(p_price * 1 / 4.0), 'PRORATE25', p_days)Applied once, on the first payment_requests row for a package (period_start = purchase period), then never again; proration_mode='none' bypasses it entirely. Session banks are never bridged — the function is not called for package_type='session_bank'. wait raises no request: the student starts on the next 1st.
4.4 Idempotency — red-team finding → design response
Finding (HIGH, RT §4.3): dispatch_package_billing runs 0 * * * * with fire-and-forget net.http_post; process-package-billing deliberately continues past a 23505 to finish a prior delivery's work. A charge after that point double-charges while the first PaymentIntent is still processing.
Response. Three layers, all required:
package_charge_attempts(M10) is inserted before the Stripe call with UNIQUE(player_package_id, period_start, attempt). A second hourly run loses that insert and reads the existing row; if its status ispending,processingorsucceededit returns{skipped:true, reason:'charge_in_flight'}and touches nothing.stripe_idempotency_key = 'pkgchg:' || player_package_id || ':' || period_start || ':' || attempt, sent as theIdempotency-Keyheader. Stripe collapses a duplicate even if layer 1 is lost.- The existing compare-and-swap in
settle-payment-request.ts(UPDATE payment_requests SET status='paid' WHERE id=? AND status <> 'paid') stays the single winner-takes-all gate for granting sessions.
4.5 Failure paths, past due and SCA — red-team findings → design response
Findings (HIGH, RT §4.4/§4.6): Thai issuers routinely require 3DS, so an off-session PaymentIntent returns authentication_required, which is not non-payment; and dispatch_package_billing selects WHERE pp.status='active', so a package set past_due on day 3 is invisible to next month's dispatcher while the student is still attending.
Response.
| Stripe outcome | package_charge_attempts.status |
Package status | Student-facing action |
|---|---|---|---|
succeeded |
succeeded |
active |
receipt |
requires_action / authentication_required |
authentication_required |
stays active |
card_authentication_required template with an on-session confirmation link; the day-3 clock is suspended while an attempt is in this state and a link was sent within 48 h |
card_declined, insufficient_funds |
failed, next_retry_at = +2 days |
past_due on past_due_day |
package_past_due template |
| network / 5xx | pending, retried on the next hourly dispatch |
active |
none |
Retry schedule for failed: attempt 2 at +2 days, attempt 3 at +4 days, then abandoned and the request stays open for manual settlement. dispatch_package_billing's filter widens to status IN ('active','past_due'), and process-package-billing's package_not_active gate is amended to skip only paused, cancelled, expired and exhausted. paused is excluded from billing, and on pause next_billing_date is set forward to the first of the month after paused_until, so a resumed package does not fire immediately for a period that was never charged (RT §4.7).
4.6 What is actually chargeable — red-team finding → design response
Finding (FATAL, RT §4.2): the POS bridge maps tender to cash | card | other (20260901170806:196) and the academy UI labels PromptPay QR. Cash, transfer and PromptPay cannot be auto-charged at all.
Response, stated plainly in the product copy and in the spec. Automatic collection on the 1st covers exactly the students with an active row in customer_payment_mandates. Everyone else keeps today's lane: a payment_requests row on the 1st, reminders from reminder_offsets, settlement by a human marking it paid or by the POS. The day-1/3/7 cadence applies to both lanes; only the charge differs. On the 10 September export no Pad Thai Padel student has a mandate, because no mandate collection exists anywhere in Setpoint, so feature G ships as: collect mandates (5.3), then charge the students who gave one. Section 12 does not depend on any of this — that is principle 1.5.
RT §4.9 (partial payments). Out of scope for this release. process-package-billing raises exactly one request per period; a second payments row settling the same request produces a second POS order that the nightly reconciliation report (section 9) flags as a POS variance rather than silently netting. RT §4.11 (no POS location): the bridge returns NULL and warns; the same report flags a facility with academy payments and no active pos_locations row. RT §4.12: with a day-1 charge the default {-3,0,3,7} puts three chasers in week one; the recommended per-facility value for a card-charged academy is {0,3,7}, set in settings, not in a migration.
5. Stripe object mapping
5.1 Mapping
| Setpoint entity | Stripe object | Notes |
|---|---|---|
customers.stripe_customer_id |
Customer | Already written by apps/admin/src/app/api/v1/_lib/stripe-customer.ts:18-43. Reused as-is; no new column, no stripe_customers table. |
customer_payment_mandates (M14) |
PaymentMethod + SetupIntent | setup_intent_id for provenance, stripe_payment_method_id for the charge, consent_at for the mandate record Stripe requires for off-session use. |
player_packages + payment_requests period |
PaymentIntent | amount = payment_requests.amount_amount, currency = facilities.currency, customer, payment_method, off_session: true, confirm: true, Idempotency-Key per 4.4. |
package_charge_attempts |
(no Stripe object) | Local attempt ledger; stripe_payment_intent_id links out. |
payments.stripe_payment_id |
PaymentIntent id | The matching key used by reconciliation (section 9). |
payment_requests.checkout_id |
Checkout Session id | Unchanged; still the key settle-payment-request.ts matches on. |
player_packages.stripe_subscription_id |
Subscription | Legacy lane, untouched (AC-41). |
Metadata on every PaymentIntent: {facility_id, academy_id, player_package_id, payment_request_id, period_start, kind:'academy_package'}. kind is the discriminator the webhook branches on, following the existing meta.type convention in apps/admin/src/app/api/v1/webhooks/stripe/route.ts.
5.2 Webhook
Events consumed: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.requires_action, setup_intent.succeeded, payment_method.detached.
Extend the existing apps/admin/src/app/api/v1/webhooks/stripe/route.ts, do not add a new endpoint. It already has a payment_intent.succeeded branch dispatching on meta.type (:805-883) and already shares settle-payment-request.ts with the Xendit webhook. supabase/functions/pos-stripe-webhook/index.ts is the POS terminal webhook, handles only status='payment_requested' tickets and stays out of this. A second endpoint would mean a second signing secret and a second settlement path for the same payment_requests row.
5.3 Mandate collection
New route POST /api/admin/academy/payment-mandates/setup-intent (staff-initiated, returns a SetupIntent client secret and a short-lived link) and POST /api/v1/me/payment-mandates (student-initiated from the web app). setup_intent.succeeded writes the customer_payment_mandates row. This is the long pole of feature G and is not a change to process-package-billing (RT §4.1).
5.4 Test keys to live keys
facilities.payment_provider was backfilled to NULL for every facility by 20260824123907_payment_provider_nullable_backfill.sql, so today no facility can take an online payment; CLAUDE.md records that production runs Stripe test keys by design. Moving one facility to live keys is therefore a gate, not a task in this spec: set facilities.payment_provider='stripe' for that facility, swap the environment's Stripe secret, and re-run the mandate flow, because test-mode PaymentMethods and Customers do not exist in live mode — every mandate collected under test keys must be collected again. The spec's ordering (mandates first, charging second) makes that re-collection the same work either way. Whether Pad Thai Padel's Setpoint instance is live-key ready is UNVERIFIED.
6. Roster follows package
6.1 Generation from slots
generate_group_lessons(group_id, from_date, to_date):
slots := active academy_group_slots for group_id
for each slot, for each date D in [from_date, to_date] where dow(D) = slot.weekday:
if a lesson already exists with (academy_group_slot_id = slot.id and local_date(start_time) = D): continue
lesson := create_lesson_atomic(
class_id := slot.class_id ?? group.class_id,
coach_id := slot.coach_id ?? group.coach_id,
court_ids := [slot.court_id],
start/end := D + slot.start_time, + slot.duration_minutes,
class_type := 'group',
players := []) -- roster attached below, never inside the atomic
set lesson.academy_group_id, lesson.academy_group_slot_id
for each member M in academy_group_members(group_id) ordered by joined_at:
pkg := select_covering_package(M.user_id, D)
if pkg is null:
insert lesson_players(user_id := M, attendance_state := 'scheduled',
coverage_state := 'uncovered', payment_method := 'pending')
else if D outside pkg.current_period:
insert lesson_players(..., coverage_state := 'pending_period', player_package_id := null)
else:
insert lesson_players(..., payment_method := 'package')
reserve_package_session(pkg.id, class_id, lesson.id, lesson_player.id) -- locks, may refuse
on refusal: set coverage_state := 'uncovered', player_package_id := null
Reuses POST /api/admin/lessons/recurring's computeOccurrenceDates and RECURRENCE_SOFT_CAP_DAYS from @setpoint/common/recurrence; only group classes may recur, which is already enforced there.
6.2 Coverage predicate and package selection
select_covering_package(user_id, lesson_date) is deterministic and returns at most one row:
FROM player_packages pp JOIN lesson_packages lp ON lp.id = pp.package_id
WHERE pp.user_id = :user AND pp.facility_id = :facility
AND pp.status IN ('active','past_due') -- past_due still attends until the freeze
AND ( (lp.package_type = 'recurring'
AND :lesson_date BETWEEN local_date(pp.current_period_start) AND local_date(pp.current_period_end))
OR (lp.package_type = 'session_bank'
AND (pp.expires_at IS NULL OR :lesson_date <= local_date(pp.expires_at))) ) -- NULL = never expires
AND get_player_package_remaining_sessions(pp.id) > 0
ORDER BY (pp.expires_at IS NULL), pp.expires_at ASC, pp.created_at ASC
LIMIT 1
NULL expires_at means never expires (RT §3.5), matching get_player_package_remaining_sessions' own treatment, so the 62 packages with a null expiry do not fall off every roster on day one. The ORDER BY is the tie-break for the 9 students with more than one active package: soonest real expiry first, nulls last, then oldest. The chosen id is persisted on the seat (player_package_id + player_package_session_id), so the same seat never re-attributes on a later render (RT §3.6).
6.3 Two slots, four sessions — D18
A Gold B group running Monday and Wednesday generates 8–9 slots a month against an Essential entitlement of 4. The recurring route's existing "first N occurrences in date order" cap (apps/admin/src/app/api/admin/lessons/recurring/route.ts:214-219) would enrol the student for the first two weeks and nothing after — the wrong shape for a fixed-slot subscription (RT §5.3).
D18 settles it: the second weekly slot is a priced add-on, not an implication of the plan. So slot_allocation defaults to spread: with entitlement = 4 and slots = 9, reserve every ceil(9/4)-th slot in date order, giving one seat roughly every week rather than four in a fortnight. The remaining slots become coverage_state='uncovered' seats that render as "second slot not purchased" and appear on the Academy Health worklist as an upsell line, never as a silent free session. A student who buys the add-on gets a second player_packages row and the predicate in 6.2 covers both slots naturally. date_order remains selectable for a club that wants today's behaviour.
6.4 Override and lapse
Override. Only owner and admin may attach an uncovered student (D7); a manager records the reason but the route returns 403. PATCH /api/admin/lessons/[id]/players gains override_kind + override_reason, writes override_by, and writes one admin_audit_log row in the same transaction. override_kind='comp' additionally writes an admin_adjust ledger row with the reason at attendance time, so a favour granted at the desk is visible as a comped session rather than invisible to money (RT §5.4).
Lapse, dispatch_roster_sync (M16, 02:00 facility-local). For each package that left active/past_due:
- Removal happens at lapse day +
roster_grace_days—past_dueon day 3 plus 7 = day 10, three days after the day-7 freeze (D3). The freeze and the removal are deliberately different dates, so a student does not lose the ability to book and every existing seat in one notification (RT §5.5). - The job never touches a lesson starting within 48 hours. Those go to the Academy Health worklist for a human, so a 02:00 job cannot rewrite a roster the coach has already planned for tomorrow (RT §5.2).
- The job hard-excludes any seat failing
is_package_seat—paid_amount > 0, apayment_intent_id, orpayment_method <> 'package'. Paying, non-package customers on TPS-published sessions are never evicted (RT §5.1). - Every removal:
release_package_reservation,attendance_state='cancelled_by_academy', oneadmin_audit_logrow, and onepayment_remindersrow per affected coach and student usingroster_seat_released. - The job also attaches packages: seats in
coverage_state='pending_period'whose period has now started getreserve_package_sessioncalled.
OpenRally impact. lesson_players changes already emit group_lesson_player_event (emit_group_lesson_player_event() + trg_lesson_players_emit_group_session_event, 20260618000000), and lesson changes emit emit_lesson_status_event, emit_lesson_modified_event (20260813071629) and emit_lesson_court_change_event (20260813074246). Generated lessons and synced rosters therefore appear on the TPS consumer with no new plumbing — and with no new event type, which is why the notice in step 4 is a message, not an event.
7. Drag-and-drop planning (H)
Fix first: availability_override is dropped on every edit. Both edit dialogs send it (lesson-form-fields.tsx:960, lesson-form-dialog.tsx:1039) but editableFields in apps/admin/src/app/api/admin/lessons/[id]/route.ts:117-128 omits it, so the toggle does nothing and tc-264 covers only the create path. Adding it to editableFields is a one-line change that must land before drag-to-move, because a drop outside coach hours otherwise cannot carry an override.
| Concern | Design |
|---|---|
| Mutation | Reuse PATCH /api/admin/lessons/[id] with {start_time, end_time, court_ids:[X]}. It already moves the lesson and every court leg together, re-runs findCourtConflicts, recreates bookings and emits the events. No new mover. |
| Court bookings | PATCH /api/admin/bookings with {booking_id, court_id, start_time, end_time, duration_minutes}. A lesson leg returns 409 with "Lesson bookings are rescheduled through the lesson", so the drop handler branches on booking.lesson before choosing the endpoint. |
| Library | @dnd-kit/core ^6.3.1 is already a dependency; the in-repo usage pattern is court-images-dialog.tsx. No new dependency. |
| Geometry | A drop target is (courtId, tickIdx). Vertical: TICK_HEIGHT_PX = 28; horizontal: CELL_WIDTH_PX = 48; STRIDE_MINUTES = 30. newStart = dayStart + tickIdx × 30 min; duration is preserved, so end = start + duration_minutes. Snap is one tick. |
| Pre-flight | Optional GET /api/admin/lessons/[id]/can-move?court_id&start_time wrapping the existing findCourtConflicts, used only to colour the drop shadow. It is advisory; the PATCH remains authoritative. |
| Conflict | A 409 rolls the card back to its origin with a toast carrying the server message, humanised by translateCoachReservationError. A 400 from coach-availability validation offers "move anyway" only to owner/admin, which sets availability_override. |
| Undo | Client-side last-move stack (origin court + start, capped at 20) with an Undo toast that issues the inverse PATCH. The server-side record is the admin_audit_log row written by the PATCH with before/after — the undo is a new move, never a deletion of the audit row. |
| Keyboard | Every card stays a <button>. Enter picks up, arrows move by one tick or one court, Enter drops, Escape cancels; aria-live announces the candidate slot. dnd-kit's KeyboardSensor with a custom coordinate getter over the same tick geometry. |
| Coach notice | Moving a lesson emits lesson_modified already, but the coach PWA has no push of any kind (_research-planning-groups.md §6, _research-crm-messaging-roles.md §2). Notice is a lesson_rescheduled message, per D14 (email + WhatsApp now, push never in this release). |
8. Messaging (F, J)
8.1 WhatsApp Cloud API requirements
apps/admin/src/lib/messaging/providers/whatsapp-cloud.ts is a stub that throws and reads no environment variable — there is no configuration to supply yet (DEC-323). Implementing it needs, in order:
- A Meta Business account through Business verification (business documents, days to weeks).
- A dedicated sending number not already registered to a personal WhatsApp account.
- Approved message templates in the utility category. Free-form messages are blocked outside the customer-initiated window, and a payment reminder is always business-initiated.
- Env:
WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_BUSINESS_ACCOUNT_ID,WHATSAPP_ACCESS_TOKEN(system user, long-lived),WHATSAPP_APP_SECRET. Read at send time, never at module load, so the stub's replacement fails per-send rather than at boot. - The free 24-hour service window closes on 1 October 2026, so every message in this design is a paid template send, roughly USD 0.004–0.046 each depending on category and market. At ~60 students × ~4 messages a month that is single-digit dollars, which is a cost note, not a blocker.
Example bodies, seeded as system defaults (M13), utility category, variables numbered as Meta requires:
| Key | Body |
|---|---|
package_renewal_notice |
Hi {{1}}, your {{2}} at {{3}} renews on the 1st. Amount: {{4}}. Reply here if you need to pause or change your plan before the 26th. |
package_past_due |
Hi {{1}}, we could not collect {{2}} for your {{3}} at {{4}}. You can still train until the {{5}}. Settle here: {{6}} |
lesson_rescheduled |
Hi {{1}}, your {{2}} session has moved to {{3}} on court {{4}} at {{5}}. Reply here if that does not work. |
8.2 Provider slot, opt-in and fallback
resolveMessagingProvider(channel, providerId?) already exists and already refuses unknown or mismatched providers rather than falling back (apps/admin/src/lib/messaging/index.ts). The Cloud provider fills the whatsapp_cloud slot with the same MessagingProvider interface, returning status:'sent' and the Meta message id as providerMessageId. CHANNEL_DEFAULTS.whatsapp moves from whatsapp_manual to a per-facility lookup on academy_payment_settings (the column the research notes was deferred to "step 11"); a facility without Cloud configured keeps the manual wa.me lane, which stays as the permanent fallback (D6 keeps (a)). A Cloud send failure does not silently fall back — it writes payment_reminders.error and surfaces it, per AC-51.
Opt-in and opt-out. Stored per channel on the existing payment_reminders consent path is not enough, so opt-out state lives on leads/customers as a small addition: this release reuses facility_customers.email_unsubscribed? No — that table is not in production. Instead, opt-out is a row in admin_audit_log-adjacent storage would be wrong too. The correct home is one new column in a later PR; for this release, WhatsApp opt-out is recorded as a crm_customer_tags tag (whatsapp-opt-out) and checked before every send, because tags already exist, are facility-scoped and are already auditable via assigned_by. Flagged as a deliberate stop-gap, to be replaced by a first-class preference table when messaging grows beyond utility templates.
Push: none (D14). The coach PWA is Flutter with no messaging dependency and no service worker anywhere in the monorepo; push is greenfield infrastructure and is out of scope.
9. Reconciliation and attendance-versus-payment
Step 1 (PHASE-1-SPEC.md) computes three tabs live on every request. This section makes them durable so trends, not just today, are answerable.
academy_reconciliation_reports (M15) is written nightly at 03:00 facility-local by the same dispatcher pattern, one row per (facility_id, report_date), rows holding the three flagged sets and summary holding the tiles. A materialised table, not a view: the sets depend on "what was true that day" (a request paid later must not retroactively unflag a day the student trained unpaid).
| Set | Definition |
|---|---|
| scheduled-not-paid | attendance_state='scheduled' seats on a package whose current-period payment_requests row is not paid |
| attended-not-paid | consume rows in the period whose package's request for that period is not paid |
| paid-not-scheduled | a paid request for the current period with zero reserved-or-consumed seats — churn risk |
Matching keys. payments.stripe_payment_id ↔︎ PaymentIntent id; payment_requests.checkout_id ↔︎ Checkout Session id; package_charge_attempts.stripe_payment_intent_id ↔︎ PaymentIntent id. Duplicate rule: two payments rows with the same stripe_payment_id is a duplicate and is flagged, never netted; two rows against the same payment_requests.id with different provider ids is a partial payment and is flagged as a POS variance (4.6). POS variance: a payments row of type IN ('package','lesson') with status='succeeded' and amount > 0 that has no POS order with source_id = 'academy_payment:' || payments.id, which is what a facility with no active pos_locations row produces silently today.
10. Exports (K)
apps/admin has no CSV export at all; csvCell from packages/common/src/csv.ts exists and already neutralises formula injection, and is importable as @setpoint/common/csv. New route GET /api/admin/academy-exports?facility_slug&kind&date_from&date_to[&coach_ids], owner/admin/manager only, streamed via a ReadableStream with Content-Disposition: attachment, 12-month maximum range enforced with a 400 (D15: on demand, nothing stored).
| kind | Columns |
|---|---|
bookings |
booking_id, date_local, start_local, end_local, duration_minutes, court_name, booking_type, status, customer_name, customer_email, lesson_id, class_name, coach_name, price_amount_decimal, payment_status |
transactions |
row_type (payment|ledger), occurred_at_local, customer_name, customer_email, payment_id, payments_type, payment_method, amount_decimal, currency, stripe_payment_id, reverses_payment_id, ledger_id, player_package_id, package_name, reason, attendance_flavour, delta, balance_after, lesson_id, actor_email, note |
lessons |
lesson_id, date_local, start_local, duration_minutes, class_name, class_type, coach_name, court_names, status, academy_group_name, capacity, seats_confirmed, seats_attended, seats_no_show, package_seats, walkin_seats, revenue_amount_decimal |
coach_hours |
coach_name, coach_email, lessons, hours, package_sessions, walkin_sessions, commission_amount_decimal |
coach_hours reuses academy-reports/route.ts's existing arithmetic exactly: hours accumulate as duration_minutes / 60 from the lesson row, rounded to one decimal — there are no timesheets in the repo. Filters are the same facility-local boundaries from packages/common/src/datetime.ts that Step 1 uses. Amounts are exported as decimals with the minor-unit division done once, at the edge.
11. Audit model (L)
admin_audit_log (M12) is written by one helper, writeAuditLog(tx, {facilityId, actorId, action, entityType, entityId, description, before, after, metadata}), called inside the same transaction as the mutation. decisions.ts:180 is the governing rule: pos_activity_logs' writer swallows every error on purpose, and that was explicitly rejected as a model for anything with stakes. A failed audit insert aborts the operation, exactly as a failed ledger insert aborts a balance change (DEC-320).
Entities covered this release (D16 option (a), academy tables only; the table is shaped so (b) is a config change): lesson_player (reserve, release, attendance, override), player_packages (pause, resume, past_due, freeze, cancel), payment_requests (create, mark paid, revert), package_charge_attempts (charge attempted, outcome), academy_groups / academy_group_slots / academy_group_members, lessons (move, court change, cancel), leads, academy_settings, customer_payment_mandates (created, revoked).
Retention: indefinite, append-only, no UPDATE or DELETE policy. actor_id is nullable with ON DELETE SET NULL so deleting a customer anonymises the log rather than destroying it. Readers: owner, admin and manager via a new GET /api/admin/audit-log with facility scope and entity filters; coach and staff cannot read it. A requireRole(access, roles, action) helper is extracted in this PR from apps/admin/src/app/api/admin/message-templates/_lib/templates.ts:422 (the only reusable gate in the repo) and used by every new route, because the same name WRITE_ROLES currently means two different things in different files.
12. Backfill and migration strategy (the on_add → on_done switch)
Switch date 1 November 2026 (D13). October runs Step 1's worklist alongside the old ledger; the dry-run is reviewed by Jose in the last week of October.
12.1 Order of operations
- Ship M1–M4 with
ledger_mode='on_add'. Nothing changes. Reservation code paths exist but are unreachable. - Run the dry-run report (12.2) as often as wanted; it writes nothing.
- Resolve the data preconditions by hand: Growth re-platforming (12.5), the 9 multi-package students, the 62 null-expiry packages (12.6).
- Jose reviews and signs one dry-run. Its
generated_atis the snapshot key. - Run M18 in one transaction against that snapshot (12.7).
- Flip
academy_settings.ledger_mode='on_done'in the same transaction.
12.2 Dry-run report
A SECURITY DEFINER function ledger_switch_dry_run(p_academy_id uuid) RETURNS jsonb returning, per seat: the seat id, student, lesson date, current consume row if any, the inferred attendance_state, the inferred player_package_id, and the rule that produced it. Written to academy_reconciliation_reports with report_date = current_date and a kind:'ledger_switch_dry_run' marker so Jose reviews it in the same UI as everything else. Counts by rule are the summary.
12.3 Future lessons: compensating rows — red-team finding → design response
Finding (FATAL, RT §3.1): refund_package_session NULLs lessons.player_package_id (20260824200640:353), so calling it during the switch erases the evidence of which package covers each future lesson at exactly the moment the reservation model starts depending on it. HIGH §2.4: reusing an operator-facing reason for machine-reconstructed history "would actively mislead", which is why 20260825073459_backfill_balance_reason.sql exists at all.
Response. A dedicated path with a dedicated reason. For every consume row whose lesson starts after the switch instant and whose seat is a package seat:
-- inside ledger_mode_switch_commit(p_snapshot_at timestamptz), one transaction
WITH future_consumes AS (
SELECT pst.*, lp.id AS lesson_player_id, l.start_time
FROM package_session_transactions pst
JOIN lessons l ON l.id = pst.lesson_id
JOIN lesson_players lp ON lp.lesson_id = l.id AND lp.user_id = pst.user_id
WHERE pst.reason = 'consume'
AND l.start_time > p_snapshot_at
AND l.status <> 'cancelled' -- RT 3.8: never refund a seat a cancel already refunded
AND lp.attendance_state IS NULL -- idempotence is per seat, not per package
AND pst.player_package_id = lp.player_package_id
)
-- 1. reverse the balance, WITHOUT touching lessons.player_package_id
INSERT INTO package_session_transactions
(facility_id, player_package_id, player_package_session_id, package_item_id, user_id,
delta, balance_after, reason, lesson_id, class_id, actor_id, note, source, metadata)
SELECT fc.facility_id, fc.player_package_id, fc.player_package_session_id, fc.package_item_id, fc.user_id,
+1,
(SELECT pps.sessions_remaining + 1 FROM player_package_sessions pps
WHERE pps.id = fc.player_package_session_id FOR UPDATE), -- same bucket lock, so balance_after was true
'ledger_mode_switch', fc.lesson_id, fc.class_id, :actor,
'on_add -> on_done switch, seat converted to a reservation',
'backfill',
jsonb_build_object('lesson_player_id', fc.lesson_player_id, 'snapshot_at', p_snapshot_at)
FROM future_consumes fc;
-- 2. move the balance to a reservation on the same bucket
UPDATE player_package_sessions pps
SET sessions_remaining = pps.sessions_remaining + 1,
sessions_used = GREATEST(pps.sessions_used - 1, 0),
sessions_reserved = pps.sessions_reserved + 1
WHERE pps.id IN (SELECT player_package_session_id FROM future_consumes);
-- 3. mark the seat
UPDATE lesson_players lp
SET attendance_state = 'scheduled', coverage_state = 'covered',
player_package_session_id = fc.player_package_session_id
FROM future_consumes fc WHERE lp.id = fc.lesson_player_id;lessons.player_package_id is never written. The reason is ledger_mode_switch, not refund, so a dispute never reads a machine reversal as an operator refund. source='backfill' and the guard is per seat (attendance_state IS NULL), not the bucket-scoped guard backfill_package_session_ledger uses, which would no-op on every package the ledger already covers (RT §2.7).
12.4 Past lessons: inference — red-team finding → design response
Finding (FATAL, RT §3.2): checked_in_at is written only by the PWA, attended only by the coach-feedback RPCs, and most historical rows have both NULL — the 10 September export shows 698 seats, 52 check-ins and 5 attended flags. "Otherwise → no_show" would label months of ordinary, paid, attended lessons as no-shows.
Response — the rule inverts. Applied in order, first match wins:
| # | Condition | Inferred state |
|---|---|---|
| 1 | lessons.status = 'cancelled' |
cancelled_by_academy; a refund row already exists from the cascade, so nothing is written |
| 2 | checked_in_at IS NOT NULL or attended = true |
attended |
| 3 | attended = false and the lesson is inside the verified check-in window |
no_show |
| 4 | lessons.status = 'no_show' and inside the check-in window |
no_show, listed for Jose |
| 5 | a consume row exists for the seat |
attended — the session was paid for and taken |
| 6 | anything else | attended, flagged inferred_weak in the report |
The verified check-in window is the date range within which check-in was demonstrably in routine use, computed as the first through last date with at least one checked_in_at and at least 30 % of that day's seats checked in. Outside it, no_show is never inferred. Every row in rules 3, 4 and 6 appears in Jose's dry-run with its rule number.
12.5 Growth — red-team finding → design response
Finding (HIGH, RT §3.3): lesson_packages.package_type is a template column; every player_packages row points at it. Flipping session_bank → recurring retroactively changes expiry and renewal semantics for students who bought the old thing, and billing_period is CHECK-constrained at template level.
Response. D11's intent (Growth bills monthly, so it is a subscription) is kept; D11's stated method ("one-row data fix") is wrong and is not executed. Instead: create a new lesson_packages row, Growth (recurring, billing_period='monthly', billing_mode='manual', same price, same package_items), leave the old template in place and inactive (is_active=false), and migrate students one at a time on their next renewal by issuing a player_packages row against the new template. Historic rows keep pointing at the historic template and keep their historic meaning. This is a listed item in the switch preconditions, not part of M18.
12.6 The two data cohorts
- 62 packages with
expires_at IS NULL. Resolved by the coverage predicate treating NULL as never-expiring (6.2), which matchesget_player_package_remaining_sessions. No data change is required, but the dry-run lists them so Jose can set a real expiry on any that should have one. - 9 students with more than one active package. Resolved by the deterministic
ORDER BYin 6.2 and by persisting the chosen id on the seat. The dry-run lists each student with the package the rule picks, for confirmation before the switch.
12.7 Snapshot discipline — red-team finding → design response
Finding (MED, RT §3.6): between Jose reviewing and pressing commit, coaches keep checking people in.
Response. ledger_mode_switch_commit(p_snapshot_at timestamptz) takes the reviewed report's generated_at as its argument, runs as one transaction, and refuses rows that moved: any seat whose lesson_players.xmin-equivalent tracking column (attendance_marked_at, checked_in_at, or the existence of a newer ledger row for that seat) changed after p_snapshot_at aborts the whole commit with a list of the moved seats. Jose re-runs the dry-run and re-signs. Partial application is not possible.
12.8 Rollback
M19 is written and tested before M18 runs (RT §3.7). ledger_mode_switch_rollback(p_snapshot_at) reverses the three statements of 12.3 in order: set attendance_state back to NULL for seats whose only ledger row is a ledger_mode_switch row carrying that snapshot_at, restore the counters, and write the inverse ledger_mode_switch rows with delta = -1 and note = 'rollback'. It refuses to run once any seat has moved to a terminal attendance state under the new mode, because that is real new history. ledger_mode flips back to on_add in the same transaction. Both directions are exercised in tc-000-sql-evals (13.2) before the switch is scheduled.
13. Test plan
Specs live in apps/admin/e2e/tc-NNN-*.e2e.ts. The current highest is tc-283; tc-284 is Academy Health. New specs start at tc-285. Every spec: const RUN_ID = Date.now(), beforeAll calls setupBookingData(RUN_ID) and seeds extras with getSupabaseAdmin() asserting each insert's error is null, afterAll deletes extras then cleanupBookingData(data), test.setTimeout(180_000), loginViaOtp(page, testData.adminEmail), domain objects created through the real API with page.request.post(...) asserting status() === 201, dates computed in the facility timezone via toLocaleDateString("en-CA", {timeZone}). No test.skip, no if (visible), no .catch(() => {}); assertions are on rendered values via expectGenuinelyVisible.
13.1 Specs
| Feature | Spec | Assertions |
|---|---|---|
| C | tc-285-attendance-ledger.e2e.ts |
(1) a roster add under on_done renders "reserved 1" and writes no ledger row; (2) marking attended renders balance −1 and one consume row with metadata.attendance='attended'; (3) marking no-show renders the same balance with metadata.attendance='no_show'; (4) a fifth reservation on a 4-session package renders the refusal message; (5) a correction inside 48 h with a reason renders the reversal in Session activity; (6) a correction without a reason renders a validation error. |
| C | tc-286-attendance-non-package-seats.e2e.ts |
(1) a seat with paid_amount > 0 renders no "reserved" chip; (2) marking it attended leaves the ledger empty; (3) a credits seat still deducts at add; (4) releasing a hold leaves an audit row rendered in the audit view. |
| D | tc-287-package-lifecycle-states.e2e.ts |
(1) day-3 unpaid renders the past_due badge; (2) day-7 renders the freeze and blocks a roster add with a visible reason; (3) a pause declared on the 25th renders paused and no request on the 1st; (4) a pause declared on the 27th is refused with the notice-day message; (5) resume renders the next period starting on the following 1st. |
| E | tc-288-bridge-proration.e2e.ts |
(1) a package starting 22 days out renders PRORATE75 and three quarters of the price; (2) 15 days renders PRORATE50; (3) 8 days renders PRORATE25; (4) 5 days renders wait with no request; (5) a session bank renders full and is never bridged; (6) the second period renders the full price with an empty tier. |
| G | tc-289-card-charge-and-dunning.e2e.ts |
(1) a seeded mandate plus a day-1 run renders the request paid; (2) a second run in the same hour renders one attempt row, not two; (3) a declined card renders past_due on day 3; (4) authentication_required renders "action required" and not past_due; (5) a student with no mandate renders a request and reminders and no attempt. |
| B | tc-290-group-schedule-roster.e2e.ts |
(1) two slots generate lessons on both weekdays; (2) a covered member renders on every generated roster; (3) an uncovered member renders "needs package" and is excluded; (4) an owner override renders the member with the reason; (5) a manager override renders a 403 message. |
| B | tc-291-roster-sync-lapse.e2e.ts |
(1) a lapsed package's seats remain until freeze + grace; (2) after grace, future seats render released and past seats are unchanged; (3) a lesson within 48 h is untouched and appears on the worklist; (4) a paid seat is never released. |
| H | tc-292-planning-drag-move.e2e.ts |
(1) dragging a lesson card renders it in the new court and time; (2) the lesson's court booking moved with it; (3) a conflicting drop renders the 409 message and the card returns; (4) Undo restores the original slot; (5) keyboard move produces the same result; (6) an edit saving availability_override persists it (the dropped-field defect). |
| H | tc-293-lesson-waitlist.e2e.ts |
(1) a full lesson renders the waitlist button; (2) joining renders position 1; (3) a cancellation renders a claim offer with a deadline; (4) claiming renders the seat on the roster; (5) an expired claim renders position 1 for the next student. |
| F | tc-294-messaging-templates.e2e.ts |
(1) a new template key renders in the editor; (2) a WhatsApp send with the Cloud provider unconfigured renders the manual wa.me link; (3) a send to an opted-out customer renders the skip reason; (4) a failed send renders the persisted error. |
| I | tc-295-leads.e2e.ts |
(1) creating a lead renders it in the list with its source; (2) a duplicate email renders the duplicate warning; (3) converting renders the customer profile and a converted lead event; (4) the converted lead no longer appears in the open list. |
| K | tc-296-academy-exports.e2e.ts |
(1) each of the four exports downloads with the exact header row; (2) amounts render as decimals; (3) a 13-month range renders the 400 message; (4) a cell beginning with = is quote-prefixed. |
| L | tc-297-audit-log.e2e.ts |
(1) an attendance change renders an audit row with before and after; (2) an override renders the reason and the actor; (3) a coach loading the audit view renders the forbidden state; (4) a pause renders one row, not two. |
| A | tc-298-reconciliation-report.e2e.ts |
(1) the nightly report renders yesterday's three sets; (2) a duplicate stripe_payment_id renders as flagged, not netted; (3) a payment with no POS order renders as a variance; (4) a paid period with zero seats renders as churn risk. |
13.2 SQL invariants
New snippets under supabase/snippets/, run by the existing tc-000-sql-evals.e2e.ts harness, each asserting zero rows:
-- balance_after >= 0 never violated, and it equals the bucket's running sum
SELECT player_package_session_id
FROM (SELECT player_package_session_id, delta, balance_after,
SUM(delta) OVER (PARTITION BY player_package_session_id ORDER BY created_at) AS running
FROM package_session_transactions WHERE player_package_session_id IS NOT NULL) t
WHERE balance_after < 0 OR balance_after <> running;
-- reserved never exceeds purchased, and effective remaining never goes negative
SELECT id FROM player_package_sessions
WHERE sessions_reserved < 0 OR sessions_remaining - sessions_reserved < 0;
-- no double consume for one seat
SELECT metadata->>'lesson_player_id' AS seat, COUNT(*)
FROM package_session_transactions
WHERE reason = 'consume' AND metadata ? 'lesson_player_id'
GROUP BY 1 HAVING COUNT(*) > 1;
-- every consumed or reserved seat is a package seat
SELECT lp.id FROM lesson_players lp
WHERE lp.attendance_state IS NOT NULL
AND lp.player_package_id IS NOT NULL
AND (COALESCE(lp.paid_amount,0) > 0 OR lp.payment_intent_id IS NOT NULL
OR lp.payment_method <> 'package');
-- reserved counter agrees with the seats that claim it
SELECT pps.id FROM player_package_sessions pps
WHERE pps.sessions_reserved <> (
SELECT COUNT(*) FROM lesson_players lp
WHERE lp.player_package_session_id = pps.id AND lp.attendance_state = 'scheduled');
-- one charge attempt in flight per package-period
SELECT player_package_id, period_start, COUNT(*)
FROM package_charge_attempts WHERE status IN ('pending','processing')
GROUP BY 1,2 HAVING COUNT(*) > 1;14. Docs sync per PR
Every PR carries all five, and both docs apps must build.
| File | Entry |
|---|---|
apps/docs/src/data/versions.ts |
One Version per release: v0.52.0 "Academy — attendance ledger, reservations, roster follows package"; v0.53.0 "Academy — month-end billing, bridge proration, card mandates"; v0.54.0 "Academy — planning drag-and-drop, waitlist, exports, audit". Each features[].name is file-path-or-route prefixed, description ends with the decision reference, e2eTests bumped by the specs the PR adds. |
apps/docs/src/data/feature-matrix.ts |
Set setpoint to the landing version for: waitlist, makeup credits, proration, attendance-vs-payment, CSV export, player progress, drag-and-drop planning, lead entity. |
apps/apidocs/src/data/api-reference.ts |
GET /api/admin/academy-exports, GET /api/admin/audit-log, GET /api/admin/lessons/[id]/can-move, POST /api/admin/academy/payment-mandates/setup-intent, POST /api/v1/me/payment-mandates, POST /api/admin/leads, POST /api/admin/leads/[id]/convert, POST /api/admin/academy-groups/[id]/slots, POST /api/admin/academy-groups/[id]/generate-lessons, PATCH /api/admin/lessons/[id]/players (override fields). Each with auth: "jwt+admin", category Admin or Lessons. |
apps/docs/src/data/ac-<feature>.ts |
ac-academy-ledger.ts, ac-academy-billing.ts, ac-academy-planning.ts, ac-academy-crm.ts. Header comment stating the file is ephemeral, the origin quote, a numbered "WHAT THE CODEBASE AUDIT FOUND" block, a SCOPE POSTURE line, *Meta, outOfScope (each a sentence saying why), and acceptanceCriteria written as assertions naming exact tables, columns and CHECK values. |
apps/docs/src/data/decisions.ts |
Newest first, from DEC-330 (latest today is DEC-329). |
Proposed decision entries:
| id | area | Decision |
|---|---|---|
| DEC-330 | data-model | Reservations are player_package_sessions.sessions_reserved under the existing bucket FOR UPDATE, not a computed count and not a delta-0 ledger row. Alternatives: count over lesson_players — rejected: no row to lock; a release ledger reason — rejected: it mutates no counter and belongs to no bucket, breaking DEC-319/320. |
| DEC-331 | data-model | The ledger keeps one consume reason; no-show and student-cancel are metadata.attendance flavours. Alternative: no_show_consume / student_cancel_consume — rejected: the reason union is mirrored as a literal type in three apps and every reader filtering reason='consume' would silently stop counting. |
| DEC-332 | business-rule | get_player_package_remaining_sessions returns remaining minus reserved, before any flag flips. Alternative: leave it and cap the series elsewhere — rejected: lessons/recurring uses it as the cap and would enrol a 4-session student in 13 occurrences. |
| DEC-333 | data-model | academy_settings is keyed on academy_id, not facility_id. Alternative: columns on academy_payment_settings — rejected: an academy spans facilities, so a student training at two sites would get half their seats on each rule. |
| DEC-334 | data-model | The switch writes a dedicated ledger_mode_switch reason and never calls refund_package_session. Alternative: reuse refund — rejected: it NULLs lessons.player_package_id, erasing the link the reservation model depends on, and it misrepresents machine history as an operator refund. |
| DEC-335 | business-rule | Past lessons default to attended; no_show is inferred only inside a verified check-in window. Alternative: "otherwise no-show" — rejected: 52 check-ins across 698 seats means the rule would libel months of real attendance. |
| DEC-336 | business-rule | Growth becomes a new recurring template with students migrated at renewal. Alternative: re-type the existing template — rejected: it changes expiry and renewal semantics retroactively for students who bought the old thing. |
| DEC-337 | architecture | Off-session charging ships only for students with an active customer_payment_mandates row; cash, transfer and PromptPay keep the request-and-reminder lane. Alternative: imply full automation — rejected: no mandate collection exists anywhere in Setpoint and most Thai academy money cannot be charged. |
| DEC-338 | architecture | Ledger and billing ship as separate releases. Alternative: one release — rejected: moving money and moving the invariant together makes a failure undiagnosable. |
| DEC-339 | data-model | admin_audit_log is written transactionally by a shared helper. Alternative: reuse pos_activity_logs — rejected for the reason already recorded at decisions.ts:180. |
| DEC-340 | data-model | leads.converted_customer_id references customers, and conversion writes crm_customer_directory. Alternative: build on facility_customers — rejected: two migration headers confirm it is not deployed to production. |
15. Operations view (M)
Added 17 September. Feature M is the day-to-day view for coaches, community managers and staff: the same five fields on every card in the Staff PWA (apps/calendar_pwa, Flutter) and the admin app, a coach filter, events as programs, and add-player from the phone. Everything below was checked read-only in ~/cowork/setpoint on 17 September.
15.1 What exists
| Surface | Today | File |
|---|---|---|
| Staff PWA filter | Court / Coaching / Other chips plus a Show Available Slots toggle; lesson-like types normalised into coaching, events fall into court or other; no coach chip |
lib/widgets/filter_bar.dart lines 35–36, 140–142; capture of the filter sheet, 17 Sep |
| Staff PWA notes | booking_detail_sheet.dart prints bookings.notes raw, so a recurring booking shows [recurring:<uuid>] to staff; admin strips it |
lib/recurring-bookings.ts line 22, bookings-list.tsx line 237; capture of the "Deuce" sheet, 17 Sep |
| Staff PWA create | Book Court · Book Coaching · Set Availability; Book Coaching = one class + one student | lib/widgets/slot_action_sheet.dart 120–147, book_coaching_sheet.dart |
| Staff PWA detail | Coach line only if coach_name; players only for lesson-linked bookings; no add-player |
lib/widgets/booking_detail_sheet.dart 271–278, 467–500 |
| Coach's own lesson | Roster with per-player attendance, Mark complete, feedback | lib/widgets/lesson_detail_sheet.dart |
| Admin calendar filter | Court Booking / Events / Coaching; "Events" = guest_booking_category='event', "NOT the parked Events feature" |
apps/admin/src/lib/booking-category-filter.ts |
| Admin calendar card | coach_name, player_count ("+N players"); no capacity, waitlist or source |
components/admin/calendar-grid.tsx 76–128, 1249 |
| Event entity | programs (clinic / league / round_robin / camp / group_coaching / other; instructor_id, max_participants, court_id, jsonb schedule) + program_enrollments (enrolled / waitlisted / cancelled, waitlist_position) |
initial schema, programs_program_type_check |
| TPS mirror | program_session_payload → TPS events; confirm_program_enrollment, cancel_session_enrollment_by_guest; session_joined / session_left emitters → TPS event_participants |
20260616000100, 20260616120000, 20260617140000 |
| Coach in the mirror | none: program_session_payload has no instructor field |
grep, zero hits |
| Booking source | none: bookings carries no TPS reference; TPS holds booking_third_party_references |
initial schema |
15.2 Schema deltas
| # | File slug | Table | Column / constraint | Type | Default | Why |
|---|---|---|---|---|---|---|
| M20 | programs_source_external_event |
programs |
source CHECK ('setpoint','tps') · external_event_id text UNIQUE NULL · program_type + 'americano','first_time' |
text | 'setpoint' | One writer per event; a TPS event imports once; americano and first-time padel become first-class types |
| M21 | bookings_source |
bookings |
source CHECK ('setpoint','tps') |
text | 'setpoint' | The OpenRally confirm RPC sets 'tps'; a court card can say where it came from |
| M22 | program_session_payload_instructor |
function + table | program_session_payload gains instructor {id, name, email} from coaches via programs.instructor_id; emit_program_event gains an IS DISTINCT FROM guard on instructor_id; new coach_external_links (coach_id, provider='tps', external_email, confirmed_by, confirmed_at, UNIQUE) |
— | — | TPS maps the email to event_organizers; inbound matches are suggestions until confirmed once; no event storm on reassignment (or_events has no dedupe key, 20260616000100 lines 171, 226) |
| M23 | program_enrollments_staff_rls |
program_enrollments |
policies staff_manager_insert_enrollments, staff_manager_update_enrollments on status, waitlist_position, notes, user_id |
— | — | Today INSERT/UPDATE is owner/admin only (initial schema 9439, 9650); the desk cannot add a player to an event without it; external_payment_ref and hold_expires_at stay admin-only |
No change to lesson_players, lessons, classes or any ledger table. Group coaching is already classes.class_type='group'.
15.3 Card contract
One shape, computed server-side once and read by both apps:
card = { id, kind: 'court'|'private'|'group'|'event'|'match',
title, start, end, court,
coach: {id, name} | null, -- null renders "Unassigned"
players: {confirmed, capacity}, -- capacity null for court
waitlist: n,
source: 'setpoint'|'tps',
payment: 'paid_tps'|'due_desk'|'unpaid'|'package' } -- chips: Paid on TPS · Due at desk · Unpaid · Package
kind is derived: lesson with class_type='group' → group; lesson otherwise → private; program occurrence → event; matches row → match; anything else → court. Admin: GET /api/admin/calendar?coach=<id|unassigned> adds the filter and returns cards; PWA: the same derivation in Dart on the rows it already fetches (data_service.dart loads bookings, lessons, lesson_players, coaches), so PR 20 needs no new PWA endpoint.
15.4 Events as programs
- Occurrence model. Programs have no per-occurrence row:
program_next_occurrencereturns the earliest slot from today andprogram_session_payloadbuilds onesession_id = or://setpoint/session/<program_id>per program (20260616000100lines 37–52, 92–131). Feature M therefore treats an americano or first-time padel as a one-day program (one row = one occurrence, one card, one roster). A recurring clinic renders one card per date keyedprogram_id + date, with roster and payment shared across dates as today. A real occurrence table (per-date rosters and payment for recurring clinics) is a separate decision recorded under D19 and is not in PR 21. - Creation (both apps) asks "what is this?" first. Event creates a one-day
programsrow (americanoorfirst_time,instructor_id,max_participants,court_id) and the mirror does the rest; the court is blocked by the program's court booking exactly as today's TPS-initiated events block it. - Roster and waitlist read
program_enrollments; the card'swaitlistiscount(status='waitlisted'), position iswaitlist_position. One writer for capacity and roster:program_enrollmentswhensource='setpoint'(TPS joins arrive asconfirm_program_enrollmentcalls and are accepted or waitlisted here, then echoed to TPS), TPS whensource='tps'(Setpoint is read-only). Never both. - Inbound (D19 (a) until the switch): a TPS event with
club_idin Pad Thai Padel's courts arrives through the existing OpenRally webhook as a program withsource='tps',external_event_id= TPS event id; roster fromevent_participants(joined and not left), waitlist from TPS. Read-only in Setpoint: add-player on such a card is disabled with the reason "created on TPS". Echo guard, two halves: (1) the outbound mirror stores the TPS event id returned in the webhook acknowledgement intoexternal_event_idin the same transaction and the payload carriesorigin='setpoint'; (2) the importer skips any TPS event whose origin marker is Setpoint and upserts the rest onexternal_event_id, so a mirrored event can never return as a second program. The mirror never emits forsource='tps'rows. - TPS
deleted=true(20–30 % of events a month) maps toprograms.status='cancelled', never a delete. - Retire the label: once every event is a program,
guest_booking_category='event'stops being offered in the admin create dialog; existing rows keep the value and the category filter maps them tokind='event'until they age out.
15.5 Coach both ways
- Outbound: M22 adds
instructortoprogram_session_payload; TPS adds the matchedusersrow (by email) toevent_organizers. If no TPS user has that email the payload still carries the name so the TPS event shows "Coach: Carlota". - Inbound: email equality is not identity (TPS users have no Setpoint auth row; anyone can register a coach's address on TPS). A co-organiser email that matches a coach is a suggestion shown on the card ("Looks like Carlota · confirm");
instructor_idis set only when that coach, or an admin, confirms once, which writescoach_external_links(M22). Later events with a confirmed pair match automatically. No match leavesinstructor_idNULL and the card shows Unassigned with the co-organiser name as a hint. - Reassign coach is one action: it updates
programs.instructor_id, writes the audit row and emits a singlesession_updated(the change guard stops the trigger firing when nothing changed). - Unassigned is a queue, owned by the manager: Academy Health lists every event within 7 days with
instructor_id IS NULL, and the daily schedule message names those within 48 h. - Change of coach on either side is an update event, not a create; Setpoint writes an
admin_audit_logrow (PR 4) with before and after.
15.6 Add player from the PWA
| Card kind | Write | Existing path |
|---|---|---|
| private, group | lesson_players (status confirmed, is_primary only when the roster is empty) |
coach/staff RLS 20260721091646; the lesson's package seat rule from §6 applies when the ledger flag is on |
| event | confirm_program_enrollment (enrolled, or waitlisted with the next waitlist_position when count(enrolled) >= max_participants); the phone never overbooks: promoting past capacity is an owner/admin action with a reason; emits session_joined to TPS tagged payment.method_type='comped' or pay_at_venue |
20260617140000, M23 RLS |
| court | bookings.guest_first_name/last_name/phone when empty, else a note; capacity 4 |
create_booking_sheet.dart customer search |
| match | disabled: players are set in TPS | — |
Roles per D20: user_facility_roles staff and manager on any card (events need M23, today owner/admin only); coach only where lessons.coach_id = auth.uid() (the RLS already says so) and never on events. Every add writes an audit row. Money: never; the added player is pay_at_venue until a payment is attached.
15.7 Tests (Playwright only)
tc-290admin calendar: coach filter shows only that coach's cards; Unassigned shows the americano with no coach.tc-291admin calendar: an event card showscard-type,card-coach,card-players(8 / 12),card-waitlist(3),card-source(TPS).tc-292create dialog: "what is this?" → Event creates a program and the mirror emits oneprogram_sessionevent withinstructor.tc-293import: a TPS event webhook creates one program; re-delivery does not create a second (external_event_idunique).tc-294add player on a full event → waitlisted with position 1, never enrolled; on a group lesson →lesson_playersrow; as a coach on another coach's lesson → 403; as a coach on an event → 403; as staff on an event before M23 → 403, after → 201.tc-295coach link: a TPS co-organiser email matching a coach shows as a suggestion, not as the coach; confirming once setsinstructor_idand the next import matches automatically.tc-296echo: mirror a Setpoint event, replay its TPS webhook → no second program; reassign the coach twice to the same person → oneor_eventsrow.- PWA: Flutter widget tests are outside the Playwright rule; PR 20 ships with a screenshot-per-screen demo GIF as the other PRs do.
Open items, marked plainly
- D18 (settled in 04-decisions): slot allocation for a two-slot group on a four-session plan is
spread, and the second weekly slot is a priced add-on;date_orderstays selectable to reproduce today. - UNVERIFIED:
customers.default_payment_method_idsemantics; Stripe live-key readiness for Pad Thai Padel; Meta Business verification status and whether a dedicated WhatsApp number exists; whetherapps/admin/src/app/api/admin/payment-requests/_lib/reminders.tsalready mints links in a way that changes 4.6 (not read by any reader). - Stop-gap, flagged: WhatsApp opt-out stored as a
crm_customer_tagstag until a first-class preference table is justified (8.2). - Deliberate deviation from the brief: the ledger
reasonCHECK gainsledger_mode_switchonly.no_show_consume,student_cancel_consumeandreleaseare not added, for the reasons in 3.3 and DEC-331.