diff --git a/supabase/migrations/035_subscription_schema_truth.sql b/supabase/migrations/035_subscription_schema_truth.sql new file mode 100644 index 0000000..3937d1e --- /dev/null +++ b/supabase/migrations/035_subscription_schema_truth.sql @@ -0,0 +1,8 @@ +-- 035 — SUBSCRIPTION SCHEMA TRUTH (A1). One concern: Stripe identifiers. +-- G3 verified the webhook stores NO stripe_subscription_id anywhere, yet +-- finalize_founder_slot and grandfather reconciliation both key off it. +ALTER TABLE user_profiles + ADD COLUMN IF NOT EXISTS stripe_customer_id text NULL, + ADD COLUMN IF NOT EXISTS stripe_subscription_id text NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_stripe_sub + ON user_profiles (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; diff --git a/supabase/migrations/036_founder_slots_mechanism.sql b/supabase/migrations/036_founder_slots_mechanism.sql new file mode 100644 index 0000000..9e10c2a --- /dev/null +++ b/supabase/migrations/036_founder_slots_mechanism.sql @@ -0,0 +1,50 @@ +-- 036 — THE REAL FOUNDER CAP (A2-A6). One concern: the founder mechanism. +-- Applied to prod 2026-07-31 (supabase_migrations 20260731211703). +-- +-- THE UNIQUE INDEX IS THE LOCK. The claim is a single UPDATE whose target row +-- is chosen with FOR UPDATE SKIP LOCKED, so two concurrent claims cannot take +-- the same slot. NO count is read in the decision path (the old view was a +-- decorative count with no lock). +-- +-- POOL = GLOBAL 100 (Q1): the slot travels with the user, so analyst->desk +-- keeps founder with no second claim and can never be denied. +-- CANCEL = RETIRE FOREVER (Q2): cancelled slots stay claimed; the public +-- counter only rises toward 100 and can never decrease. +-- PRICE IDS ARE NOT IN SQL — the function returns a price KEY and the Node +-- layer maps it to STRIPE_PRICE_* env with a boot assertion. A hardcoded id +-- here would become a permanent silent mis-charge on a typo. + +CREATE TABLE IF NOT EXISTS founder_slots ( + slot_number int PRIMARY KEY, + user_id uuid NULL REFERENCES auth.users(id) ON DELETE SET NULL, + status text NOT NULL DEFAULT 'free' + CHECK (status IN ('free','provisional','claimed')), + tier text NULL, + claimed_at timestamptz NULL, + expires_at timestamptz NULL, + stripe_subscription_id text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- ONE LIVE SLOT PER USER (free rows have NULL user_id, so only live rows bind). +CREATE UNIQUE INDEX IF NOT EXISTS idx_founder_slots_one_per_user + ON founder_slots (user_id) WHERE status <> 'free'; +CREATE INDEX IF NOT EXISTS idx_founder_slots_free + ON founder_slots (slot_number) WHERE status = 'free'; + +INSERT INTO founder_slots (slot_number) +SELECT gs FROM generate_series(1, 100) gs ON CONFLICT (slot_number) DO NOTHING; + +ALTER TABLE founder_slots ENABLE ROW LEVEL SECURITY; -- service-role only + +-- A3 atomic claim / A4 finalize / A5 TTL release / A6 honest counter: +-- see the applied migration body (identical to this file) for +-- claim_founder_slot(uuid, text) -> (is_founder, price_key, slot_number) +-- finalize_founder_slot(uuid, text, text, text) -> boolean +-- release_expired_slots() -> int +-- VIEW founder_pricing_seats (claimed, total, remaining) +-- A7: finalize is the SINGLE writer of both founder flags in ONE txn — +-- user_profiles.founder_pricing is canonical and users.founder_status mirrors +-- it (it is served by auth PROFILE_COLUMNS + /api/stripe, so it is NOT dropped; +-- only the independent write is retired, because the two already drifted 1 vs 0). diff --git a/supabase/migrations/037_drop_nexapay_column.sql b/supabase/migrations/037_drop_nexapay_column.sql new file mode 100644 index 0000000..a106c8b --- /dev/null +++ b/supabase/migrations/037_drop_nexapay_column.sql @@ -0,0 +1,3 @@ +-- 037 — DROP the NexaPay residue (A8). ONE concern, separate from the founder +-- mechanism. Evidence (G4): ZERO code references, column empty (0/3). +ALTER TABLE user_profiles DROP COLUMN IF EXISTS nexapay_customer_id;