- 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
72 lines
6.6 KiB
SQL
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
|
|
);
|