MDX Limo
CoachLoop Master Build Document

CoachLoop Master Build Document

Version 1.3 · August 2026 · Owner: Stan — v1.1 validated against official docs 2026-08-22. v1.2 = Aug 23 call (subscriptions-only Phase 1, announcements + journal promoted). v1.3 = deep-review fixes: join/subscribe identity linking specced (8.0), standing slug join vs invitations decided, create-tenant added, minors/COPPA + environments logged in 1.4, Phase-1 consistency fixes in 6/7/8.

This is the single source of truth for building CoachLoop. It defines the product, the architecture, the schema, the money math, the conventions, and the build order. It carries forward two years of validated customer learning from the V1 system (rebuilt clean, no code reuse) plus every decision made during planning.

How to use this document (Claude Code):

  1. Read this document fully before any build session.
  2. The root CLAUDE.md should reference this file and enforce the Golden Rules (section 4).
  3. Build in the session order defined in section 11. Do not build ahead of the current phase.
  4. When this doc and an ad-hoc instruction conflict, ask. When this doc is silent, follow the official docs linked in section 14, then ask.
  5. Anything marked [V1 lesson] is paid-for knowledge from the previous system. Do not silently drop these.

1. The Product

1.1 What CoachLoop is

One platform for coaching businesses, starting with golf. Every coaching business ("tenant") gets a branded space inside a single app. Athletes join through the coach's invite link and land in that coach's branded experience.

The core loop (the product's reason to exist):

1Coach teaches lesson → assigns practice plan → athlete follows video drills 2at home → marks work complete → coach sees who did the reps → next lesson 3starts from reality, not a rewind

Every screen serves this loop. The frame: the coach's method owns the lesson hour; CoachLoop is the system that runs their method the other six days.

1.2 Revenue model

StreamDetail
Coach SaaS20/mocore(digitalcoaching)20/mo core (digital coaching) · 40/mo Business (adds scheduling, booking, lesson payments)
Athlete subscriptionsCoach sets price 55–50/mo. Platform fee: $5 floor + take rate on the remainder (exact math in section 7)
Split tiersCoach keeps 50% → 60% (≥500/mobilled)70500/mo billed) → 70% (≥1,000/mo). Qualifying metric: best complete prior UTC month of paid billings. Tiers ratchet and never demote [V1 lesson]
Fee capPlatform fee applies to at most the first $50 of one player's recurring revenue to one coach per UTC month, pooled across tier + add-ons [V1 lesson, #366]
One-off paymentsDeferred to Phase 2 [Aug 23 call]: Phase 1 transacts subscriptions only; lessons keep running on the coach's existing payment rails, with CoachLoop recording what's owed (lesson log). When one-offs land: platform takes zero profit — application fee only recovers Stripe processing (ceil(amount × 2.9%) + 30¢). "We never touch your lesson revenue" is literally true

1.3 Phases

PhaseScope
1 (launch bar)Tenancy + branding (logo + name only) + invite links, drills, plans, assignment, completion, coach accountability dashboard, messaging + announcements + player journal, swing upload + timestamped text/voice comments + recaps (free — no review payments), training-tier subscription via web checkout with the split. Subscriptions are the only money that moves in Phase 1 [Aug 23 call]
2Business tier: availability, booking, lesson log → lesson payments, paid swing reviews, classes/camps/waitlists, packages & credits, reconciliation, calendar sync, remote lessons, long-form content vault ("Content" — Jack's "Library"), branding customization UI
3Challenges, parent multi-profile, gamification, AI coach agent, vocabulary renaming
4AI twin, parent-as-coach D2C product, direct-to-consumer product, multi-sport scale

Phase discipline is a Golden Rule. V1 failed by building everything before shipping anything.

1.4 Open items (decisions in flux — do NOT encode in code)

  • Coach SaaS pricing restructure [Aug 23 call]: Jack is exploring Business at 80100/mowithfullpaymentprocessingasa80–100/mo with full payment processing as a 50–100/mo add-on, and/or SaaS tiers by player count. Until decided, nothing about coach tiers gets hardcoded; tier gating stays configurable.
  • Lesson log shape: Phase 1.5/2 interim before booking payments — coaches record lessons and amounts owed/paid offline. Scope it with Jack before building.
  • Vocabulary: the drills+plans coach tab keeps the name "Library"; Jack's long-form monetizable video vault (his "Library") ships in Phase 2 under a different name ("Content"). Confirmed direction, final name pending.
  • Backfill developer: a second dev may wire Stripe during Stan's honeymoon. If so, sections 7–8 of this doc are their spec; money code follows it exactly.
  • Minors / COPPA — needs a decision with Jack THIS WEEK: youth golf is a core segment, and the amended COPPA Rule has been in full effect since April 22, 2026 (verifiable parental consent before collecting personal info from under-13s, direct notice, retention limits). Pilot policy until parent multi-profile ships (Phase 3): accounts are created and owned by a parent for players under 13; the invite page states this and asks the age question. Never build under-13 self-registration.
  • Environments: one production Supabase project + local dev (supabase start) is the Phase 1 topology; Stripe test mode locally, live keys only in production env vars (Vercel / EAS / Supabase secrets — never in the repo). Error reporting (Sentry) is deliberately deferred past the pilot.

2. System Architecture

2.1 Topology

1┌─────────────────┐ ┌──────────────────────────────┐ 2│ apps/mobile │ │ apps/web (Next.js) │ 3│ Expo + Router │ │ (marketing)/ → coachloop.com│ 4│ players+coaches │ │ [slug]/ → invite+checkout 5└───────┬─────────┘ │ portal/ → coach admin │ 6 │ └──────────┬───────────────────┘ 7 │ reads: supabase-js + RLS │ 8 │ mutations touching money/ │ 9 │ entitlements: edge functions │ 10 ▼ ▼ 11┌──────────────────────────────────────────────────┐ 12│ Supabase: Postgres (RLS) · Auth · Storage · │ 13│ Edge Functions (Deno) · pg_cron │ 14└───────────────┬──────────────────────────────────┘ 15 │ webhooks (signed) ▲ 16 ▼ │ destination charges 17 ┌──────────────┐ ┌────────────────┐ 18 │ Stripe │◄────────│ Coach Connect │ 19 │ platform acct │ │ Express accts │ 20 └──────────────┘ └────────────────┘

2.2 Tenancy model

  • tenants is the root: one row per coaching business. Branding (identity color, logo, display name, slug) lives here.
  • Single app, runtime tenant resolution. V1 baked BRAND_ID into per-tenant binaries and needed an entire build farm; we resolve the active tenant at runtime from the user's memberships. One binary, branded spaces.
  • Global user + per-tenant membership [V1 lesson, their most important architectural decision]: auth.users is a global person. tenant_members is the authorization join: exactly one row (one role) per person per tenant. Role, coach approval, head-coach flag, per-tenant profile all live on the membership. Person-level facts (Stripe customer, Connect account, push tokens) key off the user. The dividing question: "would this differ per brand?" Yes → membership. No → user.
  • Kostis dedicated app (flagship exception): a second EAS build profile of the same codebase, published under the customer's own Apple/Google developer accounts. Never under ours. Max 2–3 such customers pre-team. Mechanics: convert the static app.json to a dynamic app.config.ts switched by an APP_VARIANT env var (bundle identifier, app name, icon, splash per variant), pair it with a dedicated eas.json build + submit profile, and store the customer's Apple/Google credentials against that profile via eas credentials. Do not build this until the pilot demands it — the env-var switch drops in without restructuring.

2.3 Authorization boundary (decided, do not relitigate)

  • Reads: mobile/web use supabase-js directly, guarded by RLS (section 6).
  • Mutations that touch money, entitlements, memberships, or cross-user state: Supabase Edge Functions using the service role, which derive the acting member server-side (JWT → user → membership in the claimed tenant) before acting. Never trust a tenant id or member id from a request body [V1 lesson].
  • Simple own-data writes (mark drill complete, send message, update own profile) may go through RLS directly with strict policies.

2.3a Mobile data layer (decided)

  • TanStack Query (React Query) is the server-state layer on mobile: every supabase-js read and edge-function call goes through useQuery/useMutation with tenant-scoped query keys (['tenant', tenantId, 'assignments', ...]). No hand-rolled useEffect fetching, no axios — the built-in fetch and supabase-js are enough.
  • Supabase client config for Expo: EXPO_PUBLIC_SUPABASE_URL + EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY in .env, holding the new sb_publishable_... key — the legacy anon/service_role JWT keys are deprecated (removal slated end of 2026); never start a new project on them. EXPO_PUBLIC_ vars are inlined at build time and visible in the shipped bundle — publishable key only; the sb_secret_... key must never appear in any EXPO_PUBLIC_ var. Session persistence uses an encrypted storage adapter passed to createClient: the documented LargeSecureStore pattern (AES-256 key in expo-secure-store, ciphertext in AsyncStorage — SecureStore rejects values over ~2 KB, so the raw session JSON can never live in it directly) or Expo's simpler expo-sqlite/localStorage adapter. Never bare AsyncStorage for tokens.
  • Drill completions are the one offline-sensitive write (players practice at ranges with bad signal): queue them through React Query mutation retry rather than inventing a sync engine. Everything else is online-only in Phase 1.

2.3b Expo Go → development build boundary (decided)

The app starts in Expo Go and stays there as long as possible — but two Phase 1 features exceed what Expo Go can run:

  • Android remote push — removed from Expo Go since SDK 53. Session 8's push-token registration can be written and iOS-tested early, but Android push verification requires a development build.
  • Native video compression — any real compression library (e.g. react-native-compressor) is a native module that Expo Go cannot load.

Decision: sessions 1–7 (the vertical slice) run entirely in Expo Go. Compression is handled at capture time first — expo-camera / expo-image-picker quality + max-duration settings, which work in Expo Go (note: ImagePicker's videoQuality is iOS-only; on Android use expo-camera's videoQuality/videoBitrate props) — and a compression library is adopted only if pilot uploads hurt. First expo-dev-client development build lands at session 8 (push — Expo now states push notifications in general, not just Android, require a development build), which also unlocks universal links for the invite deep link (custom coachloop:// scheme works everywhere; https deep links need a build with associated domains).

2.3c Realtime (decided)

Messaging (and any future live surface) uses Broadcast from Database, not postgres_changes: a trigger on messages calls realtime.broadcast_changes() into a per-thread private channel; clients subscribe with config: { private: true } after supabase.realtime.setAuth(), authorized by RLS policies on realtime.messages keyed on realtime.topic(). Supabase now recommends Broadcast over postgres_changes for exactly this workload — postgres_changes is single-threaded per project and does not apply RLS to DELETEs. Broadcast messages expire after ~3 days; missed-message catch-up is a normal React Query refetch on reconnect, not replay. Docs: https://supabase.com/docs/guides/realtime/broadcast · https://supabase.com/docs/guides/realtime/authorization

2.4 Billing architecture (decided)

  • One Stripe platform account. Every coach is a Stripe Connect connected account with the Express dashboard via controller properties — Stripe has deprecated type: 'express' for new platforms. Create accounts with controller.losses.payments = 'application', controller.fees.payer = 'application', controller.stripe_dashboard.type = 'express' (immutable after creation; requirement collection stays with Stripe). Behavior is identical to legacy Express. Docs: https://docs.stripe.com/connect/migrate-to-controller-properties
  • Every charge is a destination charge: on_behalf_of + transfer_data.destination = coach's account; platform cut = application fee. No payout code exists; Stripe settles to coach banks. Docs: https://docs.stripe.com/connect/destination-charges
  • Athlete subscriptions are sold on the web invite page (coachloop.com/{slug}) via Stripe Checkout, then the athlete downloads the app and signs in. This is the Netflix pattern and it protects the split economics from Apple's IAP commission. The app never links out to purchase and never nags about web pricing (App Review Guideline 3.1.3). Docs: https://docs.stripe.com/payments/checkout · https://developer.apple.com/app-store/review/guidelines/
  • Lesson payments (Phase 2) are person-to-person services and IAP-exempt; they can use in-app PaymentSheet.
  • Gate coach payability on webhook-mirrored charges_enabled, never on the onboarding redirect [V1 lesson].
  • Pin the Stripe API version explicitly (2025-03-31.basil or later) in the SDK client and the webhook endpoint, and code to Basil-era payloads from day one — section 8.3 lists the field moves that break naive webhook handlers.

2.5 Video (decided)

  • Client-side size control at upload (capture-time quality + max-duration via expo-camera/expo-image-picker — see 2.3b for why not a compression library at MVP), Supabase Storage, signed URLs only (store object keys, never public URLs) [V1 lesson]. Confirm-after-upload: create the DB row, upload, then mark ready. Playback via expo-video (expo-av is deprecated — never add it).
  • No server transcoding at MVP. V1 shipped raw + signed proxy and it held; revisit with Mux only if the Kostis pilot shows playback pain.
  • Recaps are never composited video [V1 lesson, their ffmpeg pipeline was retired]: store the swing video + coach's audio track + a timestamped annotation/scrub event JSON; replay all three in sync on-device.
  • Review model: timestamped comments on the video timeline (SoundCloud pattern) + recaps. Side-by-side comparison is deferred; a video switcher covers mobile comparison later.
  • Retention: players archive clips; a scheduled job hard-deletes 30-day-old archived clips including storage objects.

3. Repository & Tooling

Monorepo: pnpm workspaces + Turborepo. Docs: https://pnpm.io/workspaces · https://turborepo.com/docs

1coachloop/ 2├── apps/ 3│ ├── mobile/ # Expo, TypeScript, expo-router 4│ └── web/ # Next.js App Router 5│ └── app/(marketing)/ · [slug]/ · portal/ 6├── packages/ 7│ ├── tokens/ # design tokens (from theme/colors.ts spec) 8│ ├── types/ # generated Supabase types + domain types 9│ ├── core/ # pure business logic (money, streaks, progress) — tested 10│ └── validation/ # zod schemas shared everywhere 11├── supabase/ 12│ ├── schemas/ # declarative schema files (source of truth) 13│ ├── migrations/ # generated via `supabase db diff` 14│ ├── functions/ # edge functions 15│ └── config.toml 16├── turbo.json · pnpm-workspace.yaml · tsconfig.base.json 17├── CLAUDE.md # points here; per-app CLAUDE.md in each app 18└── .github/workflows/ci.yml # install → typecheck → lint → test
  • Expo SDK 54 (React Native 0.81, expo-router v6, New Architecture, React Compiler enabled). SDK upgrades are their own task, never a casual bump. Install native packages with npx expo install <pkg> (SDK-matched versions), never raw pnpm add. Read version-pinned docs (/versions/v54.0.0/), not /latest/. Known clock: SDK 54 support (critical fixes only) ends ~Sep–Oct 2026; SDK 57 is current, Legacy Architecture and expo-av are removed from SDK 55 onward, and Expo Go builds for SDK 55+ install via eas go rather than the app store. Schedule one dedicated SDK-upgrade session right after the vertical slice — before the pilot, not during it — and update the pinned docs paths when it lands.
  • Expo in the monorepo: SDK 52+ auto-configures Metro for monorepos (watchFolders, nodeModulesPaths) — metro.config.js stays the default getDefaultConfig(__dirname), no manual wiring. Guide: https://docs.expo.dev/guides/monorepos/
  • Declarative schema workflow: edit supabase/schemas/*.sql, generate migrations with supabase db diff -f <name>. The diff compares your schema files, not the live database — anything changed via Studio/psql is invisible to it and silently dropped, so never edit the live DB directly. Files apply in lexicographic order (override with [db.migrations] schema_paths in config.toml). Known blind spots — hand-write these into the generated migration and review every diff: DML/seed data, ALTER POLICY, view grants + security_invoker, column/schema privileges, comments. Docs: https://supabase.com/docs/guides/local-development/declarative-database-schemas
  • Type generation: supabase gen types typescript --local > packages/types/src/database.ts on every schema change. Docs: https://supabase.com/docs/guides/api/rest/generating-types
  • Supabase CLI ≥ 2.81 required (current declarative diff engine, db query, db advisors); the repo currently has 2.40.7 — upgrade before session 3. Run supabase db advisors (or MCP get_advisors) after every schema change and fix what it flags.
  • Data API exposure is opt-in now: since 2026-05-30 (enforced on all projects 2026-10-30) new tables in public are not auto-exposed to the REST API. Session 3 must explicitly expose the tables clients read and GRANT to anon/authenticated — RLS governs rows; exposure governs reachability.
  • Node 22+ is required by current supabase-js — pin CI and Vercel runtimes accordingly.
  • Deploy: Vercel (web), EAS (mobile: https://docs.expo.dev/build/introduction/), Supabase (db + functions).

4. Golden Rules (enforced in every session)

  1. Phase discipline. Nothing from a later phase gets built early, including "just the table for it."
  2. Money is integer cents, everywhere, always. All money math lives in packages/core with unit tests before any UI uses it.
  3. Mirror, don't own [V1 lesson]: Stripe is the system of record for money state. Local tables are webhook-maintained mirrors upserted on unique Stripe ids. Handlers are passthroughs of Stripe's status vocabulary.
  4. Idempotency at three layers [V1 lesson]: webhook event-id dedup table; unique upsert keys on mirrors; partial unique indexes as business-event guards paired with ON CONFLICT DO NOTHING.
  5. Enums are text + CHECK constraints generated from the zod array in packages/validation — one source, DB and DTO can never drift [V1 lesson: their best of three styles]. No Postgres enums.
  6. Soft delete over hard delete wherever history references the row (drills, plans, swings). Money rows never cascade-delete with users; they carry identity snapshots [V1 lesson: their flagged mistake].
  7. Join tables, not jsonb id arrays, for plan→drills, thread participants, and anything queried by membership [V1 lesson: their first scaling cliff].
  8. Tenant id from the server, never the request body. Cross-tenant access is a 403 before it is anything else.
  9. Components never import token primitives; they use semantic theme tokens via useTheme(). One Spring action per screen. Gradients live in cards, never behind screens.
  10. No any. TypeScript strict. Domain types come from @coachloop/types only.
  11. Store the player's IANA timezone on the membership and compute practice weeks server-side [V1 lesson: their client-passed week-start was a wart].
  12. Comments in schema files record why, not what — and get deleted when they stop being true [V1 lesson: comment drift was their main documentation hazard].

5. Phase 1 Schema

Declarative SQL for supabase/schemas/. Conventions: uuid PKs via gen_random_uuid(); timestamptz; created_at/updated_at with a shared moddatetime trigger; text+CHECK enums; snake_case. Person ids are auth.users.id (uuid). 27 tables (v1.2: payments deferred to Phase 2; announcements + journal_entries added). Every foreign key gets a covering index unless it is already the leading column of another index — Postgres does not index FKs, and cascade/RESTRICT checks plus RLS policy predicates depend on them (db advisors flags the misses; the indexes shown below are the hot ones, not the complete set). Source-of-truth handoff: at session 3 the supabase/schemas/*.sql files become the authoritative what (carrying these why-comments); this section then reduces to the table inventory + design rules and points at the files — do not maintain two copies of the DDL (rule 12 applies to this document too).

1-- 00_extensions.sql 2create extension if not exists moddatetime schema extensions; 3 4-- ═══════════════ TENANCY & IDENTITY ═══════════════ 5 6-- 01_tenants.sql 7create table tenants ( 8 id uuid primary key default gen_random_uuid(), 9 slug text not null unique 10 check (slug ~ '^[a-z0-9](?:[a-z0-9-]{1,46})[a-z0-9]$'), 11 name text not null, 12 sport text not null default 'golf', 13 status text not null default 'active' 14 check (status in ('active','suspended','archived')), 15 -- branding: identity color is the ONE color a tenant may override 16 brand_color text check (brand_color ~ '^#[0-9A-Fa-f]{6}$'), 17 logo_key text, -- storage object key, never a URL 18 created_by uuid references auth.users(id) on delete set null, 19 created_at timestamptz not null default now(), 20 updated_at timestamptz not null default now() 21); 22 23create table tenant_members ( 24 id uuid primary key default gen_random_uuid(), 25 tenant_id uuid not null references tenants(id) on delete cascade, 26 user_id uuid not null references auth.users(id) on delete cascade, 27 role text not null check (role in ('coach','player')), 28 status text not null default 'active' 29 check (status in ('invited','active','suspended','removed')), 30 -- coach gate: pending coaches 403 on all coach routes until approved. 31 -- Semantics: the tenant creator is auto-approved head coach (create-tenant fn); 32 -- additional coaches join 'pending' and the head coach approves (portal, Phase 1-lite) 33 coach_approval text check (coach_approval in ('pending','approved','rejected')), 34 is_head_coach boolean not null default false, 35 display_name text, 36 timezone text not null default 'America/Phoenix', -- IANA; server computes weeks 37 invited_by_member_id uuid references tenant_members(id) on delete set null, 38 created_at timestamptz not null default now(), 39 updated_at timestamptz not null default now(), 40 unique (tenant_id, user_id) -- one role per person per tenant [V1 lesson] 41); 42create index tenant_members_user_idx on tenant_members (user_id); 43 44-- coach settings bag, 1:1 off the membership; typed columns only for what Phase 1 uses. 45-- swing_review_price_cents is INTENTIONAL forward-compat (Golden Rule 1 exception, 46-- reviewed): 7.4 pins swing_reviews_enabled false in Phase 1; no UI reads the price yet 47create table coach_member_profiles ( 48 member_id uuid primary key references tenant_members(id) on delete cascade, 49 swing_reviews_enabled boolean not null default false, 50 swing_review_price_cents integer not null default 2000 51 check (swing_review_price_cents between 100 and 1000000), 52 created_at timestamptz not null default now(), 53 updated_at timestamptz not null default now() 54); 55 56-- 02_invitations.sql 57-- raw token lives only in the link; DB stores the hash [V1 lesson] 58create table invitations ( 59 id uuid primary key default gen_random_uuid(), 60 tenant_id uuid not null references tenants(id) on delete cascade, 61 token_hash text not null unique, -- sha256 of the raw token 62 email text, -- optional pre-address 63 role text not null check (role in ('coach','player')), 64 coach_member_id uuid references tenant_members(id) on delete set null, -- pre-wired coach for player invites 65 status text not null default 'pending' 66 check (status in ('pending','accepted','revoked','expired')), 67 expires_at timestamptz not null, 68 accepted_by uuid references auth.users(id) on delete set null, 69 accepted_at timestamptz, 70 created_by_member_id uuid references tenant_members(id) on delete set null, 71 created_at timestamptz not null default now() 72); 73 74-- 03_relationships.sql 75create table coach_student_relationships ( 76 id uuid primary key default gen_random_uuid(), 77 tenant_id uuid not null references tenants(id) on delete cascade, 78 coach_member_id uuid not null references tenant_members(id) on delete cascade, 79 player_member_id uuid not null references tenant_members(id) on delete cascade, 80 kind text not null default 'primary' check (kind in ('primary','additional')), 81 status text not null default 'active' check (status in ('active','ended')), 82 created_at timestamptz not null default now(), 83 updated_at timestamptz not null default now() 84); 85-- DB-enforced: at most one ACTIVE PRIMARY coach per student [V1 lesson] 86create unique index csr_one_primary on coach_student_relationships 87 (tenant_id, player_member_id) where (kind = 'primary' and status = 'active'); 88create unique index csr_pair_active on coach_student_relationships 89 (coach_member_id, player_member_id) where (status = 'active'); 90 91-- ═══════════════ COACHING CORE ═══════════════ 92 93-- 04_drills.sql 94create table drills ( 95 id uuid primary key default gen_random_uuid(), 96 tenant_id uuid not null references tenants(id) on delete cascade, 97 coach_member_id uuid not null references tenant_members(id) on delete cascade, 98 title text not null, 99 category text not null, -- shared constant list in packages/validation 100 difficulty text not null default 'beginner' 101 check (difficulty in ('beginner','intermediate','advanced')), 102 kind text not null check (kind in ('rep','time','video')), 103 target_reps integer check (target_reps > 0), 104 target_duration_s integer check (target_duration_s > 0), 105 description text, 106 -- ordered cues; each {text, at_s?} — at_s times the cue to the demo video; 107 -- runner falls back to even distribution when at_s is null [V1 lesson] 108 cues jsonb not null default '[]', 109 video_key text, 110 poster_key text, 111 voiceover_key text, 112 audio_mode text not null default 'original' 113 check (audio_mode in ('original','voiceover','mute')), 114 archived_at timestamptz, -- soft delete; plans keep working 115 created_at timestamptz not null default now(), 116 updated_at timestamptz not null default now(), 117 check (kind <> 'rep' or target_reps is not null), 118 check (kind <> 'time' or target_duration_s is not null) 119); 120create index drills_coach_idx on drills (tenant_id, coach_member_id, created_at desc); 121 122-- 05_plans.sql 123-- Templates + clone-on-assign [V1 lesson]: assignment always clones so bespoke 124-- edits never mutate the shared template. duration lives on the ASSIGNMENT. 125create table plans ( 126 id uuid primary key default gen_random_uuid(), 127 tenant_id uuid not null references tenants(id) on delete cascade, 128 coach_member_id uuid not null references tenant_members(id) on delete cascade, 129 title text not null, 130 notes text, 131 is_template boolean not null default true, 132 source_plan_id uuid references plans(id) on delete set null, -- clone provenance 133 archived_at timestamptz, 134 created_at timestamptz not null default now(), 135 updated_at timestamptz not null default now() 136); 137 138create table plan_sessions ( 139 id uuid primary key default gen_random_uuid(), 140 plan_id uuid not null references plans(id) on delete cascade, 141 position integer not null check (position >= 1), 142 label text not null, 143 notes text, 144 unique (plan_id, position) 145); 146 147-- real join table with ordering — replaces V1's jsonb drills array [V1 lesson §11] 148create table plan_items ( 149 id uuid primary key default gen_random_uuid(), 150 plan_session_id uuid not null references plan_sessions(id) on delete cascade, 151 drill_id uuid not null references drills(id) on delete restrict, -- drills soft-delete 152 position integer not null check (position >= 1), 153 reps_override integer check (reps_override > 0), 154 duration_override_s integer check (duration_override_s > 0), 155 coach_note text, 156 unique (plan_session_id, position) 157); 158create index plan_items_drill_idx on plan_items (drill_id); -- RESTRICT check on drill archive/delete 159 160create table plan_assignments ( 161 id uuid primary key default gen_random_uuid(), 162 tenant_id uuid not null references tenants(id) on delete cascade, 163 plan_id uuid not null references plans(id) on delete cascade, -- the clone 164 player_member_id uuid not null references tenant_members(id) on delete cascade, 165 assigned_by_member_id uuid references tenant_members(id) on delete set null, 166 starts_at timestamptz not null default now(), 167 duration_weeks integer not null default 1 check (duration_weeks between 1 and 8), 168 status text not null default 'active' 169 check (status in ('active','completed','archived')), 170 completed_at timestamptz, 171 created_at timestamptz not null default now(), 172 updated_at timestamptz not null default now() 173); 174create index pa_player_active on plan_assignments (player_member_id) where (status = 'active'); 175-- pg_cron: complete active assignments past starts_at + duration_weeks; notify player. 176 177-- 06_completions.sql 178-- every runner run-through. Links are detachable: deleting a plan keeps the 179-- player's logged history [V1 lesson] — it just stops counting toward progress. 180create table drill_completions ( 181 id uuid primary key default gen_random_uuid(), 182 tenant_id uuid not null references tenants(id) on delete cascade, 183 player_member_id uuid not null references tenant_members(id) on delete cascade, 184 drill_id uuid not null references drills(id) on delete restrict, 185 assignment_id uuid references plan_assignments(id) on delete set null, 186 plan_item_id uuid references plan_items(id) on delete set null, 187 reps_done integer check (reps_done >= 0), 188 duration_s integer check (duration_s >= 0), 189 started_at timestamptz not null default now(), 190 completed_at timestamptz, 191 created_at timestamptz not null default now() 192); 193create index dc_player_time on drill_completions (player_member_id, completed_at desc); 194create index dc_assignment on drill_completions (assignment_id) where (assignment_id is not null); 195create index dc_drill on drill_completions (drill_id); -- RESTRICT check 196-- Progress: best completed run per plan_item wins within the player's local week 197-- (weeks computed SERVER-side from tenant_members.timezone). Rep writes monotonic: 198-- max(new, existing) — you cannot un-complete within a week [V1 lesson]. 199-- Streaks are WEEKLY (product decision), anchored current-or-previous week so a 200-- streak never blanks before the player practices [V1 anchoring lesson, our unit]. 201 202-- ═══════════════ VIDEO ═══════════════ 203 204-- 07_swings.sql 205create table swings ( 206 id uuid primary key default gen_random_uuid(), 207 tenant_id uuid not null references tenants(id) on delete cascade, 208 player_member_id uuid references tenant_members(id) on delete set null, -- nullable: coach "just records" [V1] 209 coach_member_id uuid not null references tenant_members(id) on delete cascade, 210 video_key text not null, 211 poster_key text, 212 camera_view text not null default 'other' 213 check (camera_view in ('down_the_line','face_on','overhead','other')), 214 status text not null default 'uploading' 215 check (status in ('uploading','ready','reviewed','failed')), 216 review_requested boolean not null default false, -- true = "send to coach", false = silent share 217 reviewed_at timestamptz, 218 duration_s numeric, 219 trim_in_s numeric, -- non-destructive trim: clamp playback, never re-encode [V1] 220 trim_out_s numeric, 221 archived_at timestamptz, -- cron hard-deletes 30-day-old archived clips + objects 222 created_at timestamptz not null default now(), 223 updated_at timestamptz not null default now() 224); 225create index swings_coach_queue on swings (coach_member_id, created_at desc) 226 where (review_requested = true and reviewed_at is null); 227 228-- timestamped review comments on the timeline (the SoundCloud model); 229-- text or a short voice clip pinned to a moment [feature map: text/voice notes] 230create table swing_comments ( 231 id uuid primary key default gen_random_uuid(), 232 swing_id uuid not null references swings(id) on delete cascade, 233 author_member_id uuid not null references tenant_members(id) on delete cascade, 234 at_s numeric not null check (at_s >= 0), 235 body text, 236 audio_key text, -- storage object key for a voice note 237 audio_duration_s numeric check (audio_duration_s > 0), 238 created_at timestamptz not null default now(), 239 check (body is not null or audio_key is not null) 240); 241create index swing_comments_swing on swing_comments (swing_id, at_s); 242 243-- narrated recap: audio + timestamped draw/scrub events, replayed in sync 244-- on-device. NEVER server-composited [V1 lesson: retired ffmpeg pipeline]. 245create table recaps ( 246 id uuid primary key default gen_random_uuid(), 247 swing_id uuid not null references swings(id) on delete cascade, 248 coach_member_id uuid not null references tenant_members(id) on delete cascade, 249 audio_key text, 250 events jsonb not null default '[]', -- [{t, type, geometry|seek, color}] 251 duration_s numeric, 252 status text not null default 'draft' check (status in ('draft','final')), 253 finalized_at timestamptz, 254 created_at timestamptz not null default now(), 255 updated_at timestamptz not null default now() 256); 257 258-- ═══════════════ MESSAGING ═══════════════ 259 260-- 08_messaging.sql — participants are a JOIN TABLE [V1 lesson §12: their cliff] 261create table threads ( 262 id uuid primary key default gen_random_uuid(), 263 tenant_id uuid not null references tenants(id) on delete cascade, 264 kind text not null default 'dm' check (kind in ('dm')), 265 last_message_at timestamptz, 266 created_at timestamptz not null default now() 267); 268 269create table thread_participants ( 270 thread_id uuid not null references threads(id) on delete cascade, 271 member_id uuid not null references tenant_members(id) on delete cascade, 272 joined_at timestamptz not null default now(), 273 primary key (thread_id, member_id) 274); 275create index tp_member on thread_participants (member_id); 276 277create table messages ( 278 id uuid primary key default gen_random_uuid(), 279 thread_id uuid not null references threads(id) on delete cascade, 280 author_member_id uuid not null references tenant_members(id) on delete cascade, 281 body text, 282 attachment jsonb, -- {type:'swing'|'drill'|'recap', id} — validated in zod 283 created_at timestamptz not null default now(), 284 check (body is not null or attachment is not null) 285); 286create index messages_thread_time on messages (thread_id, created_at desc); 287 288create table thread_reads ( 289 thread_id uuid not null references threads(id) on delete cascade, 290 member_id uuid not null references tenant_members(id) on delete cascade, 291 last_read_at timestamptz not null default now(), 292 primary key (thread_id, member_id) 293); 294 295-- coach broadcast to their students [promoted to Phase 1 per Jack, Aug 23]; 296-- read-only for players, surfaced in the Messages tab 297create table announcements ( 298 id uuid primary key default gen_random_uuid(), 299 tenant_id uuid not null references tenants(id) on delete cascade, 300 coach_member_id uuid not null references tenant_members(id) on delete cascade, 301 title text, 302 body text not null, 303 published_at timestamptz not null default now(), 304 created_at timestamptz not null default now() 305); 306create index announcements_tenant_time on announcements (tenant_id, published_at desc); 307 308-- player practice journal [promoted to Phase 1 per Jack, Aug 23]: 309-- player writes (entry point on Today), coach reads from the player profile 310-- before the next lesson. Not chat — reflection the coach can see. 311create table journal_entries ( 312 id uuid primary key default gen_random_uuid(), 313 tenant_id uuid not null references tenants(id) on delete cascade, 314 player_member_id uuid not null references tenant_members(id) on delete cascade, 315 body text not null, 316 created_at timestamptz not null default now(), 317 updated_at timestamptz not null default now() 318); 319create index journal_player_time on journal_entries (player_member_id, created_at desc); 320 321-- ═══════════════ MONEY (Phase 1 minimum — subscriptions ONLY, per Aug 23 call) ═══════════════ 322 323-- 09_money.sql 324-- person-level Stripe identities [V1 lesson: one customer reused across coaches] 325create table stripe_customers ( 326 user_id uuid primary key references auth.users(id) on delete cascade, 327 stripe_customer_id text not null unique, 328 created_at timestamptz not null default now() 329); 330 331create table coach_stripe_accounts ( 332 user_id uuid primary key references auth.users(id) on delete cascade, 333 stripe_account_id text not null unique, 334 charges_enabled boolean not null default false, -- gate on THESE, never the redirect [V1] 335 payouts_enabled boolean not null default false, 336 details_submitted boolean not null default false, 337 disabled_reason text, 338 updated_at timestamptz not null default now() 339); 340 341-- the coach's Training Tier price. Price changes mint a NEW Stripe Price; 342-- existing subscribers keep their snapshot — no retroactive re-billing [V1]. 343create table coach_pricing ( 344 member_id uuid primary key references tenant_members(id) on delete cascade, 345 monthly_price_cents integer not null check (monthly_price_cents between 500 and 5000), 346 stripe_product_id text, 347 stripe_price_id text, 348 created_at timestamptz not null default now(), 349 updated_at timestamptz not null default now() 350); 351 352-- subscription MIRROR (webhook-maintained). `product` is a TYPED column — 353-- V1's "NULL offer_id means Training Tier" convention was their most 354-- safety-critical filter; we make it a column instead [V1 lesson §11]. 355create table subscriptions ( 356 id uuid primary key default gen_random_uuid(), 357 tenant_id uuid not null references tenants(id) on delete cascade, 358 player_member_id uuid references tenant_members(id) on delete set null, 359 coach_member_id uuid references tenant_members(id) on delete set null, 360 product text not null default 'training_tier' 361 check (product in ('training_tier')), -- add-ons append later 362 stripe_subscription_id text not null unique, 363 stripe_price_id text, 364 status text not null, -- Stripe vocabulary verbatim [V1] 365 current_period_end timestamptz, 366 cancel_at_period_end boolean not null default false, 367 created_at timestamptz not null default now(), 368 updated_at timestamptz not null default now() 369); 370create index subs_player on subscriptions (player_member_id, status); 371 372-- NOTE [Aug 23 call]: the one-off `payments` mirror (paid swing reviews, lesson 373-- payments) moved to Phase 2 with the rest of one-off money — see section 13. 374-- Per Golden Rule 1, its table is NOT created in Phase 1. When built, it carries 375-- identity snapshots and never cascade-deletes with users [V1 lesson §11.6]. 376 377-- per-invoice fee ledger: THE pooled-cap state, THE tier-qualification metric, 378-- THE recurring-revenue source [V1 lesson §5.8 — without it, exact MRR is unanswerable] 379create table subscription_invoice_fees ( 380 id uuid primary key default gen_random_uuid(), 381 stripe_invoice_id text not null unique, 382 stripe_subscription_id text not null, 383 tenant_id uuid references tenants(id) on delete set null, 384 coach_member_id uuid references tenant_members(id) on delete set null, 385 player_user_id uuid references auth.users(id) on delete set null, 386 period_start timestamptz not null, 387 period_end timestamptz not null, 388 amount_cents integer not null check (amount_cents >= 0), 389 application_fee_cents integer not null check (application_fee_cents >= 0), 390 refunded_cents integer not null default 0 check (refunded_cents >= 0), 391 paid_at timestamptz, 392 created_at timestamptz not null default now() 393); 394create index sif_pool on subscription_invoice_fees 395 (coach_member_id, player_user_id, period_start); 396 397-- webhook idempotency: insert-first claim; handler throw releases for redelivery [V1] 398create table processed_webhook_events ( 399 event_id text primary key, 400 processed_at timestamptz not null default now() 401); 402-- pg_cron: delete rows older than 90 days [V1 lesson §12.3: they never pruned]. 403 404-- 10_push.sql 405create table push_tokens ( 406 id uuid primary key default gen_random_uuid(), 407 user_id uuid not null references auth.users(id) on delete cascade, 408 token text not null unique, 409 platform text not null check (platform in ('ios','android')), 410 updated_at timestamptz not null default now() 411);

Apply the moddatetime trigger to every table with updated_at. Seed data: one demo tenant, one coach, three players, six drills, one template plan, one active assignment.


6. Row Level Security

Docs: https://supabase.com/docs/guides/database/postgres/row-level-security

Strategy: RLS is the security model for direct reads and own-data writes; edge functions (service role) handle everything else. Every table gets enable row level security.

1-- helper functions (security definer, in a private schema, STABLE) 2create schema if not exists private; 3 4create or replace function private.member_id(t uuid) 5returns uuid language sql stable security definer set search_path = '' as $$ 6 select id from public.tenant_members 7 where tenant_id = t and user_id = (select auth.uid()) and status = 'active' 8$$; 9 10create or replace function private.is_coach(t uuid) 11returns boolean language sql stable security definer set search_path = '' as $$ 12 select exists (select 1 from public.tenant_members 13 where tenant_id = t and user_id = (select auth.uid()) 14 and role = 'coach' and coach_approval = 'approved' and status = 'active') 15$$;

Policy pattern per table (Claude Code writes the full set):

Table groupReadWrite
tenantsany active member of the tenantedge functions only
tenant_membersmembers of the same tenantown row limited fields; else edge functions
drills, plans, plan_sessions, plan_itemstenant members (players see non-archived)owning coach
plan_assignmentsthe player + their coachescoaches (assign); completion status via player actions
drill_completionsown rows + coaches of that playerplayer inserts own (append-only; best run derived at read — no update path)
swings, swing_comments, recapsparticipants (player + coach)player uploads own; coach comments/recaps
threads/messagesparticipants only (via thread_participants join)participants
announcementstenant memberscoaches of the tenant
journal_entriesthe player (own rows) + coaches of that playerplayer inserts/updates own only
money tablesown rows (player sees own subs; coach sees own earnings)edge functions only, no exceptions

Rules:

  • Policies use the helper functions (so the membership subquery is written once) and always name their role with TO authenticated — never auth.role() (deprecated, and broken under anonymous sign-ins).
  • Wrap auth.uid() as (select auth.uid()) for plan caching, and index every column a policy filters on.
  • UPDATE policies carry both USING and WITH CHECK (without WITH CHECK a row can be reassigned to another user), and an UPDATE silently matches 0 rows without a SELECT policy — the pgTAP suite asserts both.
  • The private schema is never in the API's exposed schemas. Grant USAGE on it plus EXECUTE on the helpers to authenticated; revoke from anon and PUBLIC (Postgres grants EXECUTE to PUBLIC by default on new functions).
  • Recursion trap: policies on thread_participants must not query thread_participants (infinite RLS recursion). Add a private.is_thread_participant(thread_id uuid) security-definer helper and use it for threads, messages, and thread_reads.
  • Test policies with supabase test db pgTAP tests (supabase test new <name>.test scaffolds them).
  • Storage buckets (drill-videos, swings, recap-audio, brand-assets) get mirror policies on storage.objects; all access via createSignedUrl. Creating a signed URL itself requires a SELECT policy on the object, and upsert needs INSERT + SELECT + UPDATE.
  • Tables are exposed to the Data API explicitly (section 3). Money tables and processed_webhook_events are never exposed — edge-function-only, RLS enabled anyway as defense in depth. Practical consequence: subscription status and coach earnings reach clients through edge-function endpoints (get-entitlements, get-earnings), not supabase-js reads — the policy-table read grants above are the defense-in-depth layer, not the access path.

7. Money Math Specification (packages/core, fully unit-tested)

All functions pure, integer cents in, integer cents out.

7.1 Subscription platform fee [V1 lesson §5.2, verified against the public site]

1rate ∈ {0.50, 0.40, 0.30} // platform's share of the post-floor remainder 2feeUncapped(x) = x <= 500 ? x : 500 + round((x − 500) × rate) 3pooledFee(total) = feeUncapped(min(total, 5000)) // $50/player/coach/month cap 4perInvoiceFee = clamp(pooledFee(consumed + invoice) − pooledFee(consumed), 0, invoice)
  • consumed = sum of this player's paid invoice amounts to this coach in the current UTC month (query subscription_invoice_fees). Order-independent differencing.
  • Enforcement point is invoice.created: rewrite the draft invoice's application fee to perInvoiceFee (Stripe subscriptions only take a percent at create time; the per-invoice rewrite is what makes the floor+cap exact) [V1 lesson].
  • Never implement discounts as Stripe Coupons — a coupon silently dilutes the $5 floor. Mint a real discounted Price [V1 lesson, algebra preserved in their docblock].
  • Cost note: Connect subscriptions run on Stripe Billing pricing — Stripe takes its Billing fee on each invoice in addition to card processing. Platform cost; never passed to the coach.

Worked examples (test fixtures):

CaseResult
$19 sub, coach at 60/40fee = 500 + 1400×0.40 = 1060¢; coach nets 840¢ = $8.40 (matches the public site's example)
$19 sub, 50/50fee = 500 + 1400×0.50 = 1200¢; coach nets 700¢
$80 pooled in one month, 50/50pooledFee = feeUncapped(5000) = 2750¢ total for the month, regardless of how invoices split
$4 subfee = 400¢ (floor consumes it)

7.2 Volume tiers

rate resolution: explicit per-coach override → tenant default → volume tier. Tier metric: the coach's best complete prior UTC calendar month of paid subscription billings net of refunds (subscription_invoice_fees, in-progress month excluded). ≥ 5000.40;500 → 0.40; ≥ 1,000 → 0.30. Best-month-ever ⇒ tiers ratchet, never demote. Recompute monthly via pg_cron; store the current rate per coach.

7.3 One-off payments (Phase 2 — deferred per Aug 23 call; spec preserved)

applicationFee = ceil(amount × 0.029) + 30 — processing recovery only, zero platform profit. The coach absorbs processing (payer gross-up toggle later in Phase 2). Nothing in this subsection is built in Phase 1.

7.4 Review payment rule (Jon's rule — takes effect in Phase 2)

Coach-initiated review = free (no gate stops a coach annotating an unpaid swing). Player-requested review with coach charging enabled = paid before it enters the queue. isReviewPaid(initiator, coachSettings) lives in packages/core. Phase 1 behavior: all reviews are free — swing_reviews_enabled stays false platform-wide and no payment gate exists yet; the request queue works identically.

7.5 Streaks & progress (also packages/core)

  • A plan is a weekly routine: its sessions/items are due every week, repeated for duration_weeks — this single sentence drives progress, streaks, and the coach dashboard. planProgress(assignment, completions, tz) — best completed run per plan item within the player's local week (weeks start Monday 00:00 in tenant_members.timezone); completed plans report all-time, never 0%.
  • weeklyStreak(completions, tz) — consecutive weeks with a completed plan week, anchored current-or-previous week.

8. Stripe Integration

Edge functions (Deno), built on the withSupabase wrapper from npm:@supabase/server with declared auth modes — the current documented pattern. User-facing functions declare auth: 'user' and derive the acting member from ctx.userClaims (JWT verified locally against the project's JWKS; asymmetric signing keys are the default for new projects), using ctx.supabase (RLS-scoped) or ctx.supabaseAdmin (service role) as the operation requires. The webhook function declares auth: 'none' plus [functions.stripe-webhook] verify_jwt = false in config.toml, and verifies the Stripe signature over the raw body: const body = await req.text()stripe.webhooks.constructEventAsync(body, sig, secret, undefined, cryptoProvider) with Stripe.createSubtleCryptoProvider(), Stripe imported via the npm:stripe specifier. Docs: https://supabase.com/docs/guides/functions/auth · https://supabase.com/docs/guides/functions/examples/stripe-webhooks · https://docs.stripe.com/webhooks

8.0 Join & subscribe flow (the identity seam — decided)

  • The slug page (coachloop.com/{slug}) is the standing, open join surface. Joining does NOT require an invitation row: the coach shares one durable link forever. invitations are the optional pre-addressed path (coach adds a specific player/coach by email; also carries the pre-wired coach for academy routing). A tenant-level invite-only toggle is Phase 2.
  • Subscribe happens before an account exists (Netflix pattern), so linking is explicit: create-checkout stamps client_reference_id = tenant_id and subscription_data.metadata = { tenant_id, coach_member_id, claim_email } onto the Checkout Session (metadata propagates to the Subscription object); the success URL carries ?session_id={CHECKOUT_SESSION_ID} into the signup step.
  • Webhooks write the subscriptions mirror with player_member_id NULL + the claim email from metadata. Claiming: at signup/first sign-in the account-creation step passes the session_id (or the email matches), and accept-invitation/create-membership links the membership to the unclaimed subscription row — idempotent, one-shot (partial unique on claimed rows). An unclaimed subscription older than 72h triggers a nudge email; never auto-refund.

8.1 Functions

FunctionPurpose
stripe-webhookSingle endpoint; claim event id in processed_webhook_events (insert, conflict-do-nothing, throw releases claim) then dispatch
create-checkoutInvite page → Checkout Session for the coach's Training Tier price (destination charge subscription, application fee percent estimate; trial injection later). Stamps the 8.0 linking metadata + client_reference_id; success URL carries {CHECKOUT_SESSION_ID}
create-review-paymentPhase 2 [Aug 23 call — not built in Phase 1]: player-requested paid review → PaymentIntent (destination charge, fee per 7.3)
connect-onboardingCreate connected account (controller properties, Express dashboard — see 2.4) + Account Link for a coach
create-tenantCoach signup (marketing → portal): claim slug, create tenant + coach membership (auto-approved, is_head_coach = true), then hand off to connect-onboarding
accept-invitationVerify token hash, create membership + relationship transactionally; claims any unclaimed subscription per 8.0
assign-planClone template → per-player plan, create assignment, notify

8.2 Webhook events handled (Phase 1)

EventEffect
account.updatedRe-fetch account, mirror verification booleans onto coach_stripe_accounts (ordering-safe) [V1]
checkout.session.completedLink session → local records. Under Basil, subscription-mode sessions create the subscription only at payment completion — read it from this event, never earlier (one-off/review sessions: Phase 2)
customer.subscription.created/updated/deletedRe-fetch and upsert the subscriptions mirror
invoice.createdThe fee authority: compute perInvoiceFee (7.1), rewrite the draft invoice's application fee, upsert subscription_invoice_fees
invoice.paidStamp paid_at; entitlement follows mirror status + current_period_end
invoice.payment_failedZero that invoice's row out of the pool [V1]
payment_intent.succeededPhase 2 (one-off payments only): flip payments row (matched on our stored PI id / PI metadatapayment_intent.invoice no longer exists, see 8.3); mark swing paid
payment_intent.payment_failed/canceledPhase 2: advance in-flight rows; never overwrite succeeded/refunded [V1]
charge.refundedPhase 1 effect: update subscription_invoice_fees.refunded_cents for the invoice's row (feeds the 7.2 tier metric). Full refunds only; partial handling deferred, documented [V1]

A 5-minute pg_cron settle sweep covers missed webhooks — Phase 1 scope: completed Checkout Sessions with no local subscription row, and subscriptions whose mirror is stale vs current_period_end (re-fetch and upsert). One-off purchase sweeping joins in Phase 2 [V1]. Cron jobs that need an edge function call it through pg_net's net.http_post (documented Supabase Cron pattern), authenticated with a secret key against a function declaring auth: 'secret'.

Push notifications (Phase 1 matrix): sends go through one send-push edge function calling the Expo Push API with stored push_tokens, checking receipts and pruning dead tokens (docs: https://docs.expo.dev/push-notifications/sending-notifications/). Events that push: new message · announcement published · plan assigned · coach "send reminders" nudge · swing review completed. Nothing else pushes in Phase 1.

8.3 API version pin (Basil) — payload rules

Pin the API version (2025-03-31.basil or later) and follow these rules — each is a field that existed pre-Basil and silently vanished from payloads:

Old readBasil-era rule
invoice.subscriptioninvoice.parent.subscription_details.subscription (guard on invoice.parent.type === 'subscription_details')
invoice.payment_intent / invoice.chargeGone. Use the invoice.payments list, or GET /v1/invoice_payments?payment[payment_intent]=… to go the other way
invoice.application_fee_amount (response field)Gone from the Invoice object — but the update parameter still works: the 7.1 rewrite on invoice.created (POST /v1/invoices/{id} with application_fee_amount) is unchanged and remains Stripe's documented pattern. To read a fee back, expand the payment's PaymentIntent
payment_intent.invoiceGone. Correlate one-off PIs via our payments row keyed on the PI id, and set PI metadata at creation

The draft-invoice window for the 7.1 fee rewrite is ~1 hour after invoice.created (Stripe delays auto-finalization up to 72h while the webhook endpoint isn't returning 2xx). The rewrite must land before finalization; if the invoice is no longer editable the handler throws, releasing the idempotency claim for redelivery.


9. Design System (summary — full spec in theme/colors.ts + tokens package)

  • Stage: neutral near-black, whisper-cool. void #090B0E · canvas #101316 · card #1B1E22 · raised #272A2F · line #393E42. Text #E9EAE3 (APCA 93, no halation). Dark-first; light mode = paper #F8F9F1 with darkened accents.
  • Function colors (never tenant-overridden): action Spring #DBE532 (ONE per screen, glow instead of shadow), progress Mantis #76C968, danger #FD736D, warning #FFB256, info #76B6F7. P3 variants on iOS.
  • Tenant brand = identity chrome only (logo tile, header accent) via tenants.brand_color.
  • Gradients live in cards, never behind screens. Recipes: Under the Lights (hero cards), Momentum (streaks), Fairway (earnings), Daybreak (web/light only). Spring never touches paper.
  • Type: Archivo (display) · Mona Sans (body) · Geist Mono (data). Static font files load one file per weight (expo-font); text styles set weight via fontFamily name, never fontWeight (iOS synthesizes or falls back otherwise). Rounded-2xl cards, hairline borders.

Mobile theme bridge contract (session 2 builds this; enforced after):

  • ThemeProvider/useTheme() is the single entry point — exactly one theme source, never a second token file beside it. Screens import components; components import semantic tokens; nobody imports primitives.
  • Shared primitives (Button, Card, ThemedText, EmptyState, …) define an explicit contract: variants (visual intent), sizes (mapped to spacing/type tokens, never fresh numbers), states (default / pressed / disabled / loading — every tappable element gets pressed feedback via a Pressable style function), and a style prop merged last (callers may override layout, never identity). When a component's props start describing content, switch to children.
  • Extraction rule: inline JSX → colocated screen component → shared component, promoted only when it appears on 2+ screens and has a nameable role. Never speculatively.
  • Motion tokens live in @coachloop/tokens too (fast 150ms press feedback · base 250ms element transitions · slow 400ms sheets/screens) so animation feels related across the app.
  • RN idioms: glow/shadows are boxShadow strings (never legacy shadow*/elevation props); non-capsule radii pair with borderCurve: 'continuous'. Theme values stay static hex (already true of Color System v3) — never feed PlatformColor/dynamic colors into Reanimated styles.
  • After building or changing a screen, run the four-check pass: hierarchy (type ramp, not ad-hoc sizes), proximity (gap + spacing tokens), repetition (corners/shadows/accents match — an escaped value moves into the theme), alignment (one screen edge padding). A screen failing the same check twice means the fix belongs in the theme or a component, not the screen.

10. App Information Architecture

  • Coach tabs: Home · Library · Players · Schedule (Phase 2, hidden on core tier) · Messages. Business/settings behind avatar. ("Library" = drills + plans. Jack's long-form monetizable video vault — his word for "Library" — is a separate Phase 2 feature named "Content"; see 1.4.)
  • Player tabs: Today · Progress · Book (Phase 2) · Messages.
  • Journal [Aug 23 call]: entry button on the player's Today screen (top-right); coach reads entries from the player profile before the next lesson. Not a tab, not chat.
  • Announcements: coach composes from Players; players read them in the Messages tab (read-only feed alongside the coach thread).
  • Coach Home keeps the "everything findable at a glance" quality Jack validated with coaches [Aug 23 call]: pending queue + upcoming lessons + quick actions as clear tappable blocks with single-word/short labels. No analytics dashboards — Jack is explicitly skeptical of them; the earnings snapshot is one number.
  • The two hero screens (best design effort lives here):
    • Player Today: coach's brand header, Under-the-Lights plan card with progress ring, drill rows with checkmarks, one glowing Start Drill, Momentum streak card. Invite-link → seeing "here's your plan" in under a minute.
    • Coach Home: "Who did the work" — roster grouped On Track (progress) / Behind Pace (warning) / Slipping (danger), pending review queue, one action: Send Reminders.
  • Drill runner: story-style (demo loops, cues appear at their timed moment, tap to advance, rep counter, satisfying Mark Complete) [V1 pattern worth keeping].
  • Web: (marketing) per the landing spec; [slug] invite page = coach brand + subscribe (Checkout) + app download + deep link; portal = coach creation surfaces (bulk drill upload, drag-drop plan builder, roster).

11. Build Order (one Claude Code session each)

#SessionDefinition of done
1Scaffold monorepopnpm typecheck && pnpm test green; both apps boot; calculateSplit imported cross-package
2Themetokens package + ThemeProvider/useTheme; hidden /dev/theme screen renders every token + gradient
3Schema + RLSCLI upgraded ≥2.81; declarative schemas → db diff -f migration; tables explicitly exposed to the Data API with role grants (section 3); pgTAP policy tests; db advisors clean; db:types generates; seed loads
4Auth + invite (web)[slug] page renders tenant brand (standing open join per 8.0); email OTP auth; create-tenant fn (coach signup → slug claim → auto-approved head coach); accept-invitation/membership creation claims unclaimed subscriptions (8.0); signup asks the age question and routes under-13 to parent-owned accounts (1.4 COPPA policy)
5Mobile shellauth, tenant-space resolution + theming, tab navs, empty states; data layer per 2.3a (Supabase client w/ secure session storage, QueryClientProvider, EXPO_PUBLIC_ env vars)
6Librarycoach: drill CRUD (record/upload → compressed → storage → confirm), plan builder (sessions + items)
7The loopassign-plan fn; player Today + drill runner + completion; coach Home accountability board; progress/streak from packages/core
8Messagingthreads/participants/messages/reads; broadcast-from-database realtime (2.3c: trigger + private channels + realtime.messages RLS); push token registration; announcements (coach compose + player feed) and player journal (Today entry point + profile view). First expo-dev-client development build (2.3b): remote push does not run in Expo Go
9Video reviewswing upload, coach queue, timestamped comments (text + voice), recap record/replay — all reviews free in Phase 1 (7.4)
10MoneySubscriptions only [Aug 23 call]: Connect onboarding, coach pricing, invite-page Checkout, webhook fn with the subscription events in 8.2 (account, checkout, subscription, invoice rows), fee math wired, earnings card. No PaymentIntents, no review payments. Reference pattern: expo/exampleswith-stripe (adapt, never copy pinned versions)
11Polish + pilot prepseed demo tenant "Kostis Golf", empty/error/loading states, eas.json (appVersionSource: "remote", autoIncrement, production + development profiles), TestFlight via eas build -p ios --profile production --submit (EAS builds are metered — batch them)

The vertical slice (pre-wedding demo): sessions 1–7 end-to-end — invite link → join branded space → see plan → run drill → mark complete → coach sees it on Home. Sessions 8–11 land after, money last. The dedicated Expo SDK-upgrade session (section 3's known clock) slots in between the slice and the pilot.


12. Explicitly Out of Scope (Phase 1)

No booking/scheduling/classes, no one-off payment processing of any kind — paid swing reviews, lesson payments, packages: subscriptions are the only money that moves in Phase 1 [Aug 23 call], no credits/packages/gifts, no trials/intro pricing/per-student overrides, no challenges, no parent multi-profile, no AI anything, no CRM/investor/ops tooling, no add-on products, no long-form content vault ("Content"), no branding customization UI beyond logo + name, no drawing tools or side-by-side video, no calendar sync, no vocabulary renaming, no per-coach feature flags. (Announcements and the player journal are in Phase 1 as of v1.2.) Each exclusion has a validated V1 design waiting in section 13 — build them when their phase arrives, not before.


13. V1 Patterns Library (adopt when the phase needs them — not before)

PatternPhaseThe lesson
One-off payments mirror2The deferred payments table (removed from Phase 1 schema in v1.2): identity snapshots on every row, nullable FKs, never cascade-deletes with users [V1 lesson §11.6]; purpose enum grows (swing_review, booking, …)
Lesson log2Interim before booking payments [Aug 23 call]: coach records lessons + amounts owed/paid offline; the "translate what to charge" accounting view for coaches keeping their existing payment rails
Credit wallet2Append-only credit_ledger + maintained credit_balances with CHECK balance ≥ 0; partial-unique index per business event + ON CONFLICT DO NOTHING = double-apply is physically impossible
Trial grants2Consume the grant only when the webhook-mirrored subscription actually carries the trial — never at checkout creation (abandoned checkout must not burn the offer)
Intro pricing2Store the promise (intro_cycles 1–24), derive the expiry; persisting only the timestamp caused three documented defects
Per-student overrides2Dedicated pinned Stripe Price per override; base-price propagation skips overridden players
Booking engine2Snapshot price at booking; materialized occurrences (no client RRULE); saved-card deferred charges via SetupIntent; RESTRICT protecting paid registrations
Academy routing2payment_destination='academy' routes charges to head coach; coach_id keeps attribution, destination_coach_id records who was paid
Payer-covers-fees2total = ceil((base + 30)/(1 − 0.029)); platform absorbs estimate noise; coach nets exactly base
Challenges3Coach-roster-scoped only (facility-wide leaked between coaches); rep/time/none metric with none the default; proof toggle
Twin/RAG4Review-queue → nightly canon fold; retrieval traces need retention from day one (their unbounded-growth table #1)

14. Reference Library

Expo / React Native — the app is SDK 54: always read /versions/v54.0.0/ docs, never /latest/ (latest tracks the newest SDK and documents APIs this project doesn't have)

Supabase

Stripe

Apple / Google

Tooling


End of master document. Keep it truthful: when a decision changes, change it here first.

CoachLoop Master Build Document | MDX Limo