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
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
-- 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
|
||||
);
|
||||
Reference in New Issue
Block a user