-- Phase 9 — the narrow commerce/payment-record model around Phase 8's registration system. -- Stripe (or any future provider) is a COMMERCE provider only — this schema is what RootNode -- itself understands, never a copy of Stripe's own object model. No card numbers, no CVV, no -- raw payment-method details anywhere in this schema; Stripe remains the PCI boundary. See -- docs/PHASE9_COMMERCE_AND_LAUNCH.md. -- ═══════════════════════════════════════════════════════════════════════════════════════════ -- offerings — price is nullable: a NULL price means this offering never requires checkout (the -- existing Phase 7/8 free-registration path is completely unchanged for it). amount is in minor -- currency units (cents), matching Stripe's own convention, so no float-vs-integer rounding -- question ever arises. -- ═══════════════════════════════════════════════════════════════════════════════════════════ ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_amount INTEGER; -- cents; NULL = free/no-checkout ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_currency TEXT NOT NULL DEFAULT 'usd'; ALTER TABLE offerings DROP CONSTRAINT IF EXISTS offerings_price_amount_nonnegative; ALTER TABLE offerings ADD CONSTRAINT offerings_price_amount_nonnegative CHECK (price_amount IS NULL OR price_amount >= 0); -- ═══════════════════════════════════════════════════════════════════════════════════════════ -- offering_registrations — reservation_expires_at is set only for a registration against a -- PAID offering (commerce.js's own responsibility, not a DB default) — the abandoned- -- reservation reaper (Step 27) releases the seat and cancels the registration once this passes -- with no successful payment, exactly the "reserve at registration, expire if unpaid" model -- Step 26 asks to prove. NULL forever for a free offering — nothing new to expire there. -- ═══════════════════════════════════════════════════════════════════════════════════════════ ALTER TABLE offering_registrations ADD COLUMN IF NOT EXISTS reservation_expires_at TIMESTAMPTZ; -- ═══════════════════════════════════════════════════════════════════════════════════════════ -- payment_transactions — one row per checkout attempt (a registration can have more than one, -- e.g. a failed attempt followed by a retry). provider_reference is the provider's own opaque -- id (a Stripe Checkout Session id) — never a card/payment-method identifier. amount/currency -- are SNAPSHOTTED from the offering at checkout-creation time (matching every other -- "session snapshots its own terms" precedent in this schema — assessment sessions, agreement -- acceptances) so a later price change never reinterprets an already-created transaction. -- ═══════════════════════════════════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS payment_transactions ( transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), registration_id UUID NOT NULL REFERENCES offering_registrations(registration_id), provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')), provider_reference TEXT, -- e.g. Stripe Checkout Session id status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'cancelled', 'refunded', 'partially_refunded')), amount INTEGER NOT NULL, -- cents, snapshotted from the offering at creation currency TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), authorized_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, failed_at TIMESTAMPTZ, refunded_at TIMESTAMPTZ, cancelled_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_payment_transactions_registration ON payment_transactions(registration_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_transactions_provider_reference ON payment_transactions(provider_reference) WHERE provider_reference IS NOT NULL; -- ═══════════════════════════════════════════════════════════════════════════════════════════ -- payment_webhook_events — the ENTIRE webhook idempotency mechanism: a real DB-level unique -- primary key on the provider's own event id (Stripe already guarantees these are globally -- unique), not a SELECT-then-process check. A duplicate delivery of the same event (Stripe -- explicitly documents retries as expected/normal) always tries the same INSERT, always loses -- the race gracefully via ON CONFLICT DO NOTHING, and is therefore always a safe no-op no -- matter how many times or how concurrently it arrives. -- ═══════════════════════════════════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS payment_webhook_events ( webhook_event_id TEXT PRIMARY KEY, -- the provider's own event id provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')), event_type TEXT NOT NULL, transaction_id UUID REFERENCES payment_transactions(transaction_id), result TEXT NOT NULL DEFAULT 'received' CHECK (result IN ('received', 'processed', 'ignored', 'error')), received_at TIMESTAMPTZ NOT NULL DEFAULT now(), processed_at TIMESTAMPTZ );