Files
rootnode-academy/platform/api/migrations/037_phase9_commerce.sql
T
ced4568 b0e07bb17f feat(commerce): add payment-provider abstraction, commerce layer, and pricing schema
- migration 037: offerings.price_amount/price_currency, offering_registrations.reservation_expires_at,
  payment_transactions, payment_webhook_events (webhook_event_id as real PK — the idempotency mechanism)
- payment-provider.js: StripeProvider (real SDK, HMAC webhook verification) + TestProvider
  (deterministic, in-process, no network) behind one interface, selected via PAYMENT_PROVIDER
- commerce.js: createCheckoutSession (server-side price, never caller-supplied), processWebhookEvent
  (idempotent), handlePaymentSuccess/Failure/CheckoutAbandoned/Refund, reservation reaper support
- offerings.js: computeReservationExpiry, updateOffering (allowlisted PATCH), readiness gained
  price/payment_provider checks, convertRegistrationToEnrollment now fires the activation email
  every other enrollment path already triggers, and links the registration's guardian_id into
  student_guardians on conversion (real bug: a converted minor had no guardian link, so the new
  activation email had nowhere to resolve a recipient to)
- server.js: raw-body middleware scoped to /api/webhooks/payment, reservation-reaper poller
2026-08-27 09:05:19 -05:00

72 lines
6.6 KiB
SQL

-- 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
);