Setpoint Academy proposal · Working fileAll deliverables · Scope · Mockups

Setpoint — session ledger, packages, billing, payments, reminders, POS bridge

Read-only research. Repo root: /Users/Alvaro/cowork/setpoint. All paths below are relative to it.


1. package_session_transactions (the ledger)

Created: supabase/migrations/20260824200640_package_session_ledger.sql:21-48

Column Type Notes
id uuid PK gen_random_uuid()
facility_id uuid NOT NULL FK facilities, ON DELETE CASCADE
player_package_id uuid NOT NULL FK player_packages, CASCADE
player_package_session_id uuid FK player_package_sessions, CASCADE (deliberate, see :60-67)
package_item_id uuid FK package_items, SET NULL
user_id uuid NOT NULL FK auth.users, CASCADE
delta integer NOT NULL signed change
balance_after integer NOT NULL CHECK >= 0
reason text NOT NULL CHECK, see below
lesson_id uuid FK lessons SET NULL (refund must stay traceable)
class_id uuid FK classes SET NULL
actor_id uuid FK auth.users SET NULL
note text operator reason lives here
source text NOT NULL default 'live'; CHECK ('live','backfill')
metadata jsonb NOT NULL default {}
created_at timestamptz NOT NULL clock_timestamp(), not now() — multiple rows per transaction must be orderable (:39-44)

reason CHECK values

Original set (20260824200640.sql:46): purchase_grant, consume, refund, admin_adjust, renewal_reset, expiry_forfeit, cancellation_forfeit

Widened in supabase/migrations/20260825073459_backfill_balance_reason.sql:27-33 to add backfill_balance. Final set = the 7 above + backfill_balance.

expiry_forfeit and cancellation_forfeit are declared but never written by any code path — no INSERT anywhere uses them. Ledger-visible forfeiture on expiry/cancel does not exist today.

Indexes (:92-96)

(player_package_id, created_at DESC), (user_id, created_at DESC), (facility_id, created_at DESC), (player_package_session_id), partial (lesson_id) WHERE lesson_id IS NOT NULL.

RLS (:100-113)

adjust_package_sessions() signature

20260824200640.sql:521

adjust_package_sessions(
  p_player_package_id uuid,
  p_adjustments       jsonb,   -- [{"session_id":uuid,"sessions_remaining":int,"sessions_used":int}, ...]
  p_reason            text,    -- REQUIRED, raises P0001 if blank
  p_actor_id          uuid DEFAULT NULL
) RETURNS json   -- { adjusted, buckets_changed, sessions_remaining }

SECURITY DEFINER, REVOKE ALL FROM PUBLIC, GRANT ... TO service_role only (:610-612). Locks each bucket FOR UPDATE, refuses a session_id from another package, CONTINUEs on a no-op bucket, writes one admin_adjust row per changed bucket with metadata = {previous_sessions_remaining, previous_sessions_used, sessions_used}.

Who inserts ledger rows (complete list)

All in 20260824200640_package_session_ledger.sql + the backfill migration. No TypeScript ever inserts.

Writer Line reason delta counters mutated on player_package_sessions
log_package_session_grant() AFTER INSERT trigger on player_package_sessions :132-167 purchase_grant NEW.sessions_remaining none (fires on bucket creation)
consume_package_session(pkg, class, lesson) :186-292 consume -1 sessions_remaining -1, sessions_used +1
refund_package_session(pkg, class, lesson DEFAULT NULL) :328-414 refund +1 sessions_remaining +1, sessions_used = GREATEST(used-1,0)
reset_package_sessions(pkg, period_start, period_end) :424-506 renewal_reset pi.session_count - pps.sessions_remaining (one row per bucket, written before the reset) sessions_remaining = pi.session_count, sessions_used = 0
adjust_package_sessions(...) :521-608 admin_adjust new_remaining - old_remaining set to the supplied values
backfill_package_session_ledger() :879-1019 purchase_grant / consume / backfill_balance, source='backfill' derived none

Important: the ledger only ever tracks sessions_remaining. player_packages itself has no counters — the counter pair lives on player_package_sessions (one bucket per package_item). SUM(delta) per bucket == that bucket's sessions_remaining is the stated invariant (:87-88), asserted at the end of the migration (:1028+).

Note a correctness wrinkle worth flagging: refund_package_session restores sessions_remaining but adjust_package_sessions writes sessions_used only into metadata, never as a ledger quantity. sessions_used history is therefore reconstructible only through renewal_reset.metadata.previous_sessions_used.

Read API

apps/admin/src/app/api/admin/students/[userId]/package-transactions/route.ts:91 — the only TS reader.


2. DEDUCT-ON-ADD — where a session is actually taken

2a. Lesson creation → create_lesson_atomic

Redefined verbatim-plus-two-lines in 20260824200640_package_session_ledger.sql:635-860.

Group/any lesson, per player (:806-824 of the file, inside the lesson_players loop):

INSERT INTO lesson_players (
  lesson_id, user_id, email, phone, is_primary, payment_method, player_package_id
) VALUES (...);

IF v_player_payment = 'credits' AND p_per_pax_amount > 0 THEN
  PERFORM record_credit_transaction(...);
ELSIF v_player_payment = 'package'
      AND v_player_package IS NOT NULL
      AND p_class_type <> 'private' THEN
  PERFORM consume_package_session(
    p_player_package_id := v_player_package,
    p_class_id          := p_class_id,
    p_lesson_id         := v_lesson.id
  );
END IF;

Private lesson, lesson-level package, consumed exactly once after the loop:

IF p_class_type = 'private'
   AND p_lesson_payment_method = 'package'
   AND p_lesson_player_package_id IS NOT NULL THEN
  PERFORM consume_package_session(
    p_player_package_id := p_lesson_player_package_id,
    p_class_id          := p_class_id,
    p_lesson_id         := v_lesson.id
  );
END IF;

Then every linked booking with payment_method='package' is flipped to bookings.payment_status='paid' in the same transaction.

So: deduction happens at lesson/player CREATION, not at check-in, not at attendance. There is no lesson_players INSERT trigger — the consume is an explicit PERFORM inside create_lesson_atomic.

2b. Adding a player to an existing lesson

apps/admin/src/app/api/admin/lessons/[id]/route.ts:492-512 — the PATCH player-sync block. For each player in toAdd:

if (isGroupLesson && p.payment_method === "package" && p.player_package_id && lessonClassInfo?.class_id) {
  const { error: consumeError } = await supabase.rpc("consume_package_session", {
    p_player_package_id: p.player_package_id,
    p_class_id: lessonClassInfo.class_id,
    p_lesson_id: id,          // AC-3
  });
  if (consumeError) return NextResponse.json({ error: `Failed to deduct package session for ${p.email}: ...` }, { status: 400 });
}

The lesson_players rows are inserted after the consume loop (:521) — not atomic with it.

2c. Other consume call sites

Flag: both v1 routes still call the backwards-compatible 2-arg overload (20260824200640.sql:304-316), which writes lesson_id = NULL. AC-3 traceability is unmet on the player-facing booking flow.

2d. Cancel / player removal → refund

Player removed from a lesson — apps/admin/src/app/api/admin/lessons/[id]/route.ts:468-482:

for (const p of toRemove) {
  if (p.player_package_id && lessonClassInfo?.class_id) {
    await (supabase as any).rpc("refund_package_session", {
      p_player_package_id: p.player_package_id,
      p_class_id: lessonClassInfo.class_id,
    });   // NOTE: no p_lesson_id, and the error is not checked
  }
}

Then the rows are deleted. The refund error is swallowed here — the only such swallow in this area.

Lesson cancelled — apps/admin/src/lib/cancel-lesson-cascade.ts, the single source of truth, called from PATCH /api/admin/lessons/[id], PATCH /api/admin/bookings, POST /api/v1/me/bookings/[id]/cancel:

  1. Idempotency gate: returns {alreadyCancelled:true} if lessons.status === 'cancelled' (:59-61).
  2. Per-player refund (:68-85): refund_package_session(p_player_package_id, p_class_id) — no p_lesson_id — then lesson_players gets player_package_id = null, payment_method = 'pending' so a re-run cannot double-refund. Throws on error.
  3. Lesson-level refund (:87-98): refund_package_session(pkg, class, p_lesson_id: lessonId) — this one does pass the lesson.
  4. Cancels linked bookings; 5. flips lessons.status='cancelled', which fires emit_lesson_status_event → lesson_cancelled OpenRally event.

Refund reason on the ledger is always 'refund' (delta +1). refund_package_session also nulls lessons.player_package_id to prevent double-refund (:353), and transitions the package exhausted → active only (never cancelled/expired).

2e. Check-in and attendance — do they touch the ledger?

No. Neither does anything at all to sessions or the ledger.

2f. Is there a status column on lesson_players?

No. Full column list (00000000000000_initial_schema.sql:3783-3798 + additive migrations): id, lesson_id, user_id, email, phone, is_primary, joined_at, payment_intent_id, paid_amount (int, >=0), payment_method (text, unconstrained), player_package_id, checked_in_at, attended, coach_feedback, coach_rating.

There is no status enum and no CHECK on payment_method at the lesson_players level. Values used in code: 'package', 'credits', 'pending', 'stripe', 'xendit', plus whatever the mark-paid path writes.


3. Package tables

player_packages (00000000000000_initial_schema.sql + 3 additive migrations)

Column Notes
id uuid PK
package_id uuid NOT NULL FK lesson_packages
user_id, facility_id uuid NOT NULL
purchased_at timestamptz NOT NULL default now()
expires_at timestamptz NULL = never expires
status text NOT NULL default 'active' CHECK ('active','exhausted','expired','cancelled')
stripe_subscription_id text presence = Stripe owns the billing
stripe_payment_intent_id text
created_at timestamptz
current_period_start, current_period_end timestamptz recurring period tracking
payment_method text NOT NULL default 'complimentary' CHECK ('in_club','payment_link','credits','complimentary') — 20260608125709_package_payment_method.sql:9-12
payment_status text NOT NULL default 'waived' CHECK ('pending','paid','waived') — same migration
billing_mode text NOT NULL default 'manual' CHECK ('automatic','manual') — 20260824200650:35-40
next_billing_date date 20260824200650:46-47
billing_anchor_day smallint CHECK 1..31 — :52-57; index idx_player_packages_next_billing

There is no price_amount on player_packages — the price lives on lesson_packages.price_amount and must be joined.

lesson_packages (the template)

id, academy_id NOT NULL, facility_id, name, description, package_type (CHECK 'session_bank'|'recurring', default 'session_bank'), price_amount int >= 0, billing_period, valid_days int, schedule jsonb, is_active bool, created_at

package_items

id, package_id NOT NULL, class_id, class_type, session_count int > 0

player_package_sessions (the counters — one bucket per package_item)

id, player_package_id NOT NULL, package_item_id NOT NULL, sessions_remaining int >= 0 default 0, sessions_used int >= 0 default 0 Bucket-matching priority in consume (20260824200640:245-259): exact class_id → class_type = lesson type → class_type = 'any'; within a tier, smallest bucket first. Uses a CASE (not (cond)::int DESC) to dodge the NULLS-FIRST inversion bug documented at :228-240.


4. process-package-billing edge function

supabase/functions/process-package-billing/index.ts (+ billing-cycle.ts helpers).

Auth: requireMachineCaller(req) — accepts x-internal-secret (the cron) or the service-role key (an operator "Renew").

Flow:

  1. Parse body { player_package_id, facility_id, period_start }; 400 without player_package_id.
  2. Load player_packages (id, facility_id, user_id, package_id, status, billing_mode, next_billing_date, billing_anchor_day, stripe_subscription_id). 404 if missing.
  3. Three skip gates (each returns {skipped:true, reason} 200):
    • status !== 'active' → package_not_active
    • billing_mode !== 'automatic' → manual_billing (AC-43)
    • stripe_subscription_id present → stripe_subscription (AC-41, touches nothing, not even next_billing_date)
    • !next_billing_date → no_next_billing_date
  4. Load facility timezone, currency. Compute localToday(tz); if next_billing_date > today → not_due_yet (AC-44, club's calendar not UTC).
  5. Load lesson_packages (billing_period, price_amount). 404 if missing. 422 if billing_period IS NULL — deliberately fails loud rather than guessing "monthly".
  6. anchorDay = billing_anchor_day ?? anchorDayFrom(periodStart); periodEnd = nextBillingDate(periodStart, period, anchorDay). Both stored as the UTC instant of local midnight so the value is byte-identical on re-fire.
  7. Load academy_payment_settings; missing row falls back to {enabled:false, reminder_offsets:[-3,0,3,7], channels:['email'], send_hour_local:9, default_due_days:7}. Settings govern chasing only, never whether the invoice is raised.
  8. due_date = period_start + default_due_days (calendar days, local).
  9. Insert payment_requests unconditionally: {facility_id, user_id, subject_type:'player_package', subject_id: pkg.id, amount_amount: price_amount, currency, status:'draft', due_date, period_start, period_end} A 23505 is the expected duplicate signal (idempotency is the DB unique index payment_requests_subject_period_key, not an app read-then-write). On 23505 it looks the existing row up and continues — the reconciling steps below still run.
  10. Queue reminders: one row per offset, channel:'email', template_key:'package_payment_reminder', status:'queued', scheduled_for = local send_hour on (due_date + offset). Offsets already in the past are not queued. Inserted one at a time (not upsert — PostgREST onConflict cannot match the partial unique index). 23505 counted as reminders_already_queued. Non-email channels reported back as reminders_skipped_channels (WhatsApp has no automated transport — DEC-323).
  11. Advance the cycle last, conditionally: UPDATE player_packages SET next_billing_date = periodEnd, billing_anchor_day = anchorDay WHERE id = ? AND next_billing_date = periodStart. A crash anywhere above leaves the package still due for the next hourly run.

It never grants sessions. sessions_granted: false is returned explicitly. DEC-325: sessions are granted only when a payment_request is marked paid.

Stripe is never called from this function. No checkout, no intent, no subscription. Packages already on Stripe are skipped entirely.

Idempotency keys:

dispatch_package_billing() cron

supabase/migrations/20260824200650_academy_billing_cycles.sql:100-145; scheduled at :202-206.

SELECT cron.schedule('dispatch-package-billing',   '0 * * * *', $$SELECT public.dispatch_package_billing()$$);
SELECT cron.schedule('dispatch-payment-reminders', '0 * * * *', $$SELECT public.dispatch_payment_reminders()$$);

Hourly, both. dispatch_package_billing reads internal_function_secret from Vault (raises P0001 if missing — no silent stop), selects billing_mode='automatic' AND status='active' AND next_billing_date <= (now() AT TIME ZONE COALESCE(f.timezone,'UTC'))::date, and fires one net.http_post per package with a 10s timeout. It re-fires every hour until the processor advances the date — deliberate self-healing.

dispatch_payment_reminders() (:150-197) first UPDATEs queued reminders to 'skipped' where the request is paid/cancelled/expired, then dispatches only where aps.enabled = true.


5. Payments / reminders / templates / settings tables

All in supabase/migrations/20260824200644_academy_payments_core.sql.

payment_requests (:32-92)

id, facility_id, user_id, subject_type, subject_id, amount_amount int >= 0, currency, status, provider, checkout_id, checkout_url, checkout_expires_at, due_date date, period_start, period_end, created_by, paid_at, created_at

payment_reminders (:109-176)

id, facility_id, payment_request_id, channel, template_key, status, to_email, to_phone, subject_rendered, body_rendered, provider_message_id, error, scheduled_for, sent_at, attempt int >= 1, created_by, created_at

message_templates (:178-240)

id, facility_id (NULL = shipped system default), key, channel, name, subject, body, is_active, updated_by, created_at, updated_at

academy_payment_settings (:242-275) — this is where the "day 26 / day 3 / day 7" style settings live

Column Default CHECK
facility_id uuid PK FK facilities CASCADE
enabled boolean false ships off for every live club
reminder_offsets integer[] {-3,0,3,7} days relative to due_date
channels text[] {email} <@ ARRAY['email','whatsapp']
send_hour_local smallint 9 0..23
default_due_days integer 7 >= 0
pay_at_club_instructions text
updated_by, updated_at

There are no freeze / past-due / grace-period day settings anywhere. No "day 26" anchor exists — the billing anchor is player_packages.billing_anchor_day (1..31), a per-student day-of-month, and the due date is derived as period_start + default_due_days. Nothing suspends or freezes a package for non-payment; the only status transitions are active/exhausted/expired/cancelled (exhaustion trigger + expiry cron).

payments (00000000000000_initial_schema.sql:4090-4108 + two migrations)

id, facility_id, user_id, type, reference_id, amount int, currency (default 'usd'), status, stripe_payment_id, payment_method, created_at, bonus_amount int >= 0, notes, reverses_payment_id

POS bridge trigger

supabase/migrations/20260901170806_academy_payment_pos_bridge.sql

Reversal (20260902172043): branch keys off reverses_payment_id IS NOT NULL, never off the sign of the amount (:25). It finds the original POS order by source_id = 'academy_payment:' || reverses_payment_id and writes a POS refund; raises if the original mirror is missing (:123). backfill_academy_payments_into_pos is amended to skip reversal rows (:472).

Revert route: apps/admin/src/app/api/admin/academy-payments/revert/route.ts — POST {facility_slug, payment_id, reason}. Append-only: never updates or deletes the original. Inserts a NEW payments row with the negative amount, status:'succeeded', reverses_payment_id: original.id, notes: 'Reverted to due by staff: <reason>'. Reason is mandatory (400 without). 409 if the target is itself a reversal or already reversed. Then sets player_packages.payment_status = 'pending' and re-opens the request. Membership reverts are refused (422) — membership lifecycle is out of scope.


6. Stripe integration today


7. Acceptance criteria and decisions

apps/docs/src/data/ac-academy-payments.ts — AC-1..AC-53

Explicit out of scope (ac-academy-payments.ts:60-64): dunning/retry beyond the existing invoice.payment_failed handler; migrating clubs off Stripe test keys; issuing monetary refunds (the ledger records session refunds only); and a noted separate bug — crm_campaigns.scheduled_at has no scheduler.

apps/docs/src/data/ac-package-session-lifecycle.ts — AC-1..AC-44

Grouped: Session Consumption (1-6), Session Refund (7-10), Expiration (11-14), Exhaustion (15-18), Recurring (19-23), Lesson Generation (24-25), Admin (26-29), Web UI (30-37), Data Safety (38-42), Accessibility (43-44). Load-bearing ones: SELECT FOR UPDATE against races (AC-2); exact-then-type bucket matching, smallest bucket first (AC-3); refund only exhausted → active (AC-8); double-refund prevented by verifying lessons.player_package_id (AC-9); hourly expiry cron that also cancels future lessons and their bookings (AC-11/12); an AFTER UPDATE exhaustion trigger that also un-exhausts (AC-15/16); invoice.paid uses Stripe's own period_start/period_end as authoritative (AC-20).

Decisions DEC-319..327 (apps/docs/src/data/decisions.ts)


8. academy-reports route

apps/admin/src/app/api/admin/academy-reports/route.ts (539 lines). GET ?facility_slug&date_from&date_to&coach_ids. requireFacilityAccess() enforced; all date boundaries computed in the facility timezone via facilityDateBoundaries / utcToFacilityLocal.

Tables read: facilities, academy_facilities, academies, lessons, lesson_players, lesson_courts, bookings, player_packages (joined to lesson_packages for price_amount / package_type), player_package_sessions, coaches, customers.

It does not read package_session_transactions at all. Utilisation and consumption are derived from lesson_players.payment_method='package' counts and from the sessions_remaining + sessions_used counter pair — exactly the lossy sources DEC-319 was written to replace.

Response shape:

Revenue per player falls back through three sources in order: lesson_players.paid_amount → lesson price override/class default → the linked court booking price divided by pax. paid_amount = 0 is treated as "not recorded", not as zero revenue.


Gaps and things I could not confirm

  1. expiry_forfeit and cancellation_forfeit are dead enum values — declared in the CHECK, written by nothing. An expired package's unused sessions are forfeited silently, with no ledger row.
  2. The v1 join and confirm routes still call the 2-arg consume_package_session, so the player-facing booking flow writes lesson_id = NULL on its ledger rows. AC-3 is unmet there.
  3. Player-removal refunds drop p_lesson_id and ignore the RPC error (apps/admin/src/app/api/admin/lessons/[id]/route.ts:471-476). cancel-lesson-cascade.ts also omits p_lesson_id on its per-player refunds (:72-77) while passing it on the lesson-level one.
  4. No freeze / past-due / grace settings exist. There is no "day 26 / day 3 / day 7" config in the schema — the closest is reminder_offsets {-3,0,3,7} relative to a due date and default_due_days 7. Nothing suspends a package for non-payment.
  5. I did not read apps/admin/src/app/api/admin/payment-requests/_lib/reminders.ts or subjects.ts, academy-payments/_lib/feed.ts, summary/route.ts, or the send-payment-reminder edge function in full — AC-23's lazy link generation and AC-38's 24h rate limit live there.
  6. I did not open .env.production and ran no Supabase, npm or git commands, per the brief. Nothing here is verified against the live database.