Setpoint Academy proposal · DocumentAll deliverables · Scope · Mockups

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

  1. 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.
  2. 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.ts AC-50.
  3. 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_log in the same transaction, never fire-and-forget — decisions.ts:180 rejects the pos_activity_logs swallow-errors pattern explicitly for anything with stakes (_research-crm-messaging-roles.md §4).
  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.
  5. 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.
  6. 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.

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:

  1. Lock the lesson_players row FOR UPDATE, join its lessons row.
  2. Refuse (P0001 attendance_not_a_package_seat) when the seat is not a package seat — see 3.5.
  3. 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 after attendance_marked_at (D8: the manager corrects within 48 h with a reason; p_reason is mandatory for any correction, raising P0001 if blank, exactly like adjust_package_sessions).
  4. Lock the pinned bucket via player_package_session_id, apply the counter effects and the ledger row in the same transaction.
  5. Write attendance_state, attendance_marked_at = now(), attendance_marked_by = p_actor, and one admin_audit_log row with before/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

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:

  1. 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 is pending, processing or succeeded it returns {skipped:true, reason:'charge_in_flight'} and touches nothing.
  2. stripe_idempotency_key = 'pkgchg:' || player_package_id || ':' || period_start || ':' || attempt, sent as the Idempotency-Key header. Stripe collapses a duplicate even if layer 1 is lost.
  3. 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:

  1. Removal happens at lapse day + roster_grace_days — past_due on 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).
  2. 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).
  3. The job hard-excludes any seat failing is_package_seat — paid_amount > 0, a payment_intent_id, or payment_method <> 'package'. Paying, non-package customers on TPS-published sessions are never evicted (RT §5.1).
  4. Every removal: release_package_reservation, attendance_state='cancelled_by_academy', one admin_audit_log row, and one payment_reminders row per affected coach and student using roster_seat_released.
  5. The job also attaches packages: seats in coverage_state='pending_period' whose period has now started get reserve_package_session called.

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:

  1. A Meta Business account through Business verification (business documents, days to weeks).
  2. A dedicated sending number not already registered to a personal WhatsApp account.
  3. 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.
  4. 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.
  5. 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

  1. Ship M1–M4 with ledger_mode='on_add'. Nothing changes. Reservation code paths exist but are unreachable.
  2. Run the dry-run report (12.2) as often as wanted; it writes nothing.
  3. Resolve the data preconditions by hand: Growth re-platforming (12.5), the 9 multi-package students, the 62 null-expiry packages (12.6).
  4. Jose reviews and signs one dry-run. Its generated_at is the snapshot key.
  5. Run M18 in one transaction against that snapshot (12.7).
  6. 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

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

15.5 Coach both ways

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)

Open items, marked plainly