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:
2026-08-27 09:05:19 -05:00
parent 788fb197e0
commit b0e07bb17f
7 changed files with 541 additions and 15 deletions
@@ -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
);
+18 -1
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"express": "^4.19.2", "express": "^4.19.2",
"pg": "^8.11.5" "pg": "^8.11.5",
"stripe": "^22.6.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -1178,6 +1179,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stripe": {
"version": "22.6.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.0.tgz",
"integrity": "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw==",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+2 -1
View File
@@ -10,8 +10,9 @@
"bootstrap-owner": "node bootstrap-owner.js" "bootstrap-owner": "node bootstrap-owner.js"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.19.2", "express": "^4.19.2",
"pg": "^8.11.5", "pg": "^8.11.5",
"better-sqlite3": "^9.4.3" "stripe": "^22.6.0"
} }
} }
+223
View File
@@ -0,0 +1,223 @@
// Phase 9 — checkout/payment business logic. Sits ABOVE offerings.js (registration/agreement/
// conversion) and payment-provider.js (the Stripe/test abstraction) — this file is the only
// place either one gets combined with the other. Same error convention as offerings.js/
// enrollment.js: throw `Error` with a `.code` the route layer maps to an HTTP status.
const offerings = require('./offerings');
const notificationsLib = require('./notifications');
const metrics = require('./metrics');
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function err(code, message) {
return Object.assign(new Error(message || code), { code });
}
// ── Checkout creation (Step 5/6) ─────────────────────────────────────────────────────────────
// Verifies, in order: registration exists and hasn't already gone past the point checkout makes
// sense; the offering actually has a price configured (a free offering never needs this at
// all); registration is still open (mirrors offerings.js's own assertRegistrationOpen, since an
// offering can close between registration and checkout); every required agreement is already
// accepted (Step 6's explicit "before checkout"). amount/currency ALWAYS come from the
// offering's own server-side price — never from the caller, closing Step 5's explicit
// "browser cannot choose price" requirement structurally, not just by convention.
async function createCheckoutSession(pool, provider, registrationId, { successUrl, cancelUrl }) {
const reg = await offerings.getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
if (['enrollment_activated', 'cancelled'].includes(reg.status)) throw err('INVALID_STATE', `A registration in status '${reg.status}' cannot start checkout.`);
const offering = await offerings.getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
if (offering.price_amount === null) throw err('NO_PRICE_CONFIGURED', 'This offering has no price configured — it does not require checkout.');
offerings.assertRegistrationOpen(offering);
const agreementStatus = await offerings.getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const { rows } = await pool.query(
`INSERT INTO payment_transactions (registration_id, provider, status, amount, currency)
VALUES ($1, $2, 'pending', $3, $4) RETURNING *`,
[registrationId, provider.name, offering.price_amount, offering.price_currency]
);
const transaction = rows[0];
let session;
try {
session = await provider.createCheckoutSession({
amountCents: offering.price_amount, currency: offering.price_currency, productName: offering.name,
successUrl, cancelUrl, metadata: { registration_id: registrationId, transaction_id: transaction.transaction_id },
});
} catch (e) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
metrics.recordCheckoutSession('error');
throw e;
}
await pool.query(`UPDATE payment_transactions SET provider_reference = $2, updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id, session.providerReference]);
metrics.recordCheckoutSession('created');
return { transactionId: transaction.transaction_id, checkoutUrl: session.checkoutUrl, providerReference: session.providerReference };
}
// ── Webhook processing (Step 8/9/10/11) — idempotent by construction ────────────────────────
// The INSERT into payment_webhook_events is the entire idempotency mechanism (a real unique
// primary key on the provider's own event id, ON CONFLICT DO NOTHING) — if this call reports
// `duplicate`, NOTHING below it ever runs, so a payment can never be double-recorded, an
// enrollment can never be double-converted, and a notification can never be double-sent no
// matter how many times (or how concurrently) the same event arrives.
async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent) {
const { eventId, eventType, providerReference, normalizedStatus } = normalizedEvent;
const { rows: inserted } = await pool.query(
`INSERT INTO payment_webhook_events (webhook_event_id, provider, event_type) VALUES ($1, $2, $3)
ON CONFLICT (webhook_event_id) DO NOTHING RETURNING *`,
[eventId, provider.name, eventType]
);
if (!inserted[0]) {
metrics.recordWebhook(eventType, 'duplicate');
return { result: 'duplicate' };
}
if (!normalizedStatus) {
await pool.query(`UPDATE payment_webhook_events SET result = 'ignored', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'ignored');
return { result: 'ignored' };
}
const { rows: txRows } = await pool.query(`SELECT * FROM payment_transactions WHERE provider_reference = $1`, [providerReference]);
const transaction = txRows[0];
if (!transaction) {
await pool.query(`UPDATE payment_webhook_events SET result = 'error', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'error');
return { result: 'error', reason: 'UNKNOWN_TRANSACTION' };
}
await pool.query(`UPDATE payment_webhook_events SET transaction_id = $2, result = 'processed', processed_at = now() WHERE webhook_event_id = $1`, [eventId, transaction.transaction_id]);
if (normalizedStatus === 'paid') await handlePaymentSuccess(pool, studentsAuth, transaction);
else if (normalizedStatus === 'failed') await handlePaymentFailure(pool, transaction);
else if (normalizedStatus === 'cancelled') await handleCheckoutAbandoned(pool, transaction);
else if (normalizedStatus === 'refunded' || normalizedStatus === 'partially_refunded') await handleRefund(pool, transaction, normalizedStatus);
metrics.recordWebhook(eventType, 'processed');
metrics.recordPayment(normalizedStatus, provider.name);
return { result: 'processed', normalizedStatus };
}
// paid -> transaction paid -> registration confirmed -> the EXACT existing Phase 8 conversion
// flow (never duplicated here) -> notifications. Reuses offerings.transitionRegistration/
// convertRegistrationToEnrollment exactly as an admin action already would.
async function handlePaymentSuccess(pool, studentsAuth, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'paid', paid_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (!reg || ['enrollment_activated', 'cancelled'].includes(reg.status)) { metrics.recordConversion('skipped'); return; }
// Walk the registration up to 'confirmed' through whatever real transitions are actually
// still needed — a website submission may already be 'submitted'/'agreements_pending', an
// admin-entered one might still be 'started'. Only ever moves FORWARD through the same
// server-defined transition table offerings.js already enforces, never skips a real state.
const path = { started: 'submitted', submitted: 'agreements_pending', agreements_pending: 'confirmed', reviewed: 'confirmed', payment_pending: 'confirmed' };
let current = reg.status;
while (current !== 'confirmed' && path[current]) {
await offerings.transitionRegistration(pool, transaction.registration_id, path[current]);
current = path[current];
}
if (current !== 'confirmed') { metrics.recordConversion('blocked'); return; } // e.g. AGREEMENTS_INCOMPLETE would have thrown above already, but stay defensive
try {
// No separate "registration confirmed" notification here — transitionRegistration('confirmed')
// above already fired that one (Phase 8's own logic). The "you're enrolled, here's how to
// log in" signal comes from convertRegistrationToEnrollment's own notifyForEnrollment call
// (Phase 9's fix — see that function's comment) — sending both would be a real, needless
// duplicate email to the same family for the same underlying event.
await offerings.convertRegistrationToEnrollment(pool, studentsAuth, transaction.registration_id, {});
metrics.recordConversion('success');
} catch (e) {
metrics.recordConversion('error');
console.error('[commerce] enrollment conversion after payment failed:', e.message);
}
}
async function handlePaymentFailure(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg) {
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_needs_action',
metadata: { display_name: reg.registrant_display_name, offering_name: (await offerings.getOffering(pool, reg.offering_id))?.name, reason: 'Your payment did not go through — please try again.' },
}).catch((e) => console.error('[commerce] payment-failure notification failed:', e.message));
}
// Deliberately does NOT release the seat or cancel the registration — a failed payment
// attempt is not the same as an abandoned one; the registrant may simply retry checkout with
// the same reservation still held. See handleCheckoutAbandoned/the reaper for actual release.
}
// A checkout that expired without ever completing (Stripe's own checkout.session.expired, or
// the reaper's timeout below) — release the seat, cancel the registration. Never touches a
// PAID registration (both callers only ever act on a still-pending transaction/registration).
async function handleCheckoutAbandoned(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'cancelled', cancelled_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg && !['enrollment_activated', 'cancelled'].includes(reg.status)) {
await offerings.transitionRegistration(pool, transaction.registration_id, 'cancelled'); // releases the seat internally
metrics.recordSeatReleased('checkout_expired');
}
}
// Refund recording only — Step 11's explicit "do not automatically erase academic history."
// Commerce status (this table) is fully separate from registration status (offering_
// registrations.status) and academic enrollment status (enrollments.status) — a refund NEVER
// cascades into cancelling either of those automatically; that remains a real, separate
// business decision an admin makes deliberately (see docs/PHASE9_COMMERCE_AND_LAUNCH.md's
// "three separate statuses" section).
async function handleRefund(pool, transaction, status) {
const column = status === 'refunded' ? 'refunded_at' : null;
await pool.query(
`UPDATE payment_transactions SET status = $2, ${column ? `refunded_at = now(),` : ''} updated_at = now() WHERE transaction_id = $1`,
[transaction.transaction_id, status]
);
}
// ── Admin-recorded refund. No automated Stripe refund call this phase — Step 11 explicitly
// scopes this to RECORDING a refund that happened, not building a refund-management product. ──
async function recordManualRefund(pool, transactionId, status) {
if (!['refunded', 'partially_refunded'].includes(status)) throw err('VALIDATION', "status must be 'refunded' or 'partially_refunded'.");
const { rows } = await pool.query(
`UPDATE payment_transactions SET status = $2, refunded_at = now(), updated_at = now() WHERE transaction_id = $1 AND status = 'paid' RETURNING *`,
[transactionId, status]
);
if (!rows[0]) throw err('INVALID_STATE', 'Only a paid transaction can be marked refunded.');
return rows[0];
}
// ── Abandoned reservation reaper (Step 27) ───────────────────────────────────────────────────
// Finds registrations whose reservation window has passed with no successful payment, and
// releases them exactly the way an expired checkout would — same function, same audit
// footprint. Never touches a registration already enrollment_activated or cancelled (the
// WHERE clause excludes both), so a paid registration can never be swept up here regardless of
// timing.
async function reapExpiredReservations(pool) {
const { rows } = await pool.query(
`SELECT * FROM offering_registrations
WHERE reservation_expires_at IS NOT NULL AND reservation_expires_at < now()
AND status NOT IN ('enrollment_activated', 'cancelled', 'confirmed')`
);
let released = 0;
for (const reg of rows) {
await offerings.transitionRegistration(pool, reg.registration_id, 'cancelled');
released++;
metrics.recordSeatReleased('reservation_expired');
}
return { checked: rows.length, released };
}
async function listTransactionsForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM payment_transactions WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
return rows;
}
module.exports = {
RESERVATION_TIMEOUT_MINUTES, createCheckoutSession, processWebhookEvent, recordManualRefund,
reapExpiredReservations, listTransactionsForRegistration,
};
+94 -13
View File
@@ -121,15 +121,15 @@ const OFFERING_TRANSITIONS = {
cancelled: [], cancelled: [],
}; };
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy }) { async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy, priceAmount, priceCurrency }) {
if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.'); if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.');
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]); const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.'); if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by) `INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by, price_amount, price_currency)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,COALESCE($20,'usd')) RETURNING *`,
[offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null] [offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null, priceAmount ?? null, priceCurrency || null]
); );
return rows[0]; return rows[0];
} catch (e) { } catch (e) {
@@ -141,6 +141,35 @@ async function getOffering(pool, offeringId) {
const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]); const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]);
return rows[0] || null; return rows[0] || null;
} }
// Phase 9 §28 — a draft offering is created with the bare minimum, then completed over time as
// real business decisions (dates, venue, capacity, price) land — this is the one place those
// get filled in later without recreating the row. Only ever touches columns explicitly present
// in `fields`, so calling it with just `{ priceAmount, priceCurrency }` never disturbs anything
// else. Never allows changing `status` (transitionOffering is the only path for that, with its
// own server-defined transition table) or `slug`/`program_id` (identity fields).
const OFFERING_EDITABLE_FIELDS = {
name: 'name', description: 'description', startsAt: 'starts_at', endsAt: 'ends_at',
enrollmentOpensAt: 'enrollment_opens_at', enrollmentClosesAt: 'enrollment_closes_at', capacity: 'capacity',
locationId: 'location_id', organizationId: 'organization_id', cohortId: 'cohort_id',
recommendedMinAge: 'recommended_min_age', recommendedMaxAge: 'recommended_max_age',
curriculumPackageId: 'curriculum_package_id', priceAmount: 'price_amount', priceCurrency: 'price_currency',
};
async function updateOffering(pool, offeringId, fields) {
const sets = [];
const values = [offeringId];
for (const [key, column] of Object.entries(OFFERING_EDITABLE_FIELDS)) {
if (!(key in fields)) continue;
values.push(fields[key]);
sets.push(`${column} = $${values.length}`);
}
if (!sets.length) throw err('VALIDATION', 'No editable fields provided.');
const { rows } = await pool.query(
`UPDATE offerings SET ${sets.join(', ')}, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`,
values
);
if (!rows[0]) throw err('NOT_FOUND', 'No such offering.');
return rows[0];
}
async function listOfferings(pool, { status } = {}) { async function listOfferings(pool, { status } = {}) {
const { rows } = await pool.query( const { rows } = await pool.query(
status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`, status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`,
@@ -223,6 +252,15 @@ function assertRegistrationOpen(offering) {
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST // DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later // (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references. // needs to be un-created out from under a row something else already references.
// Phase 9 §26/§27 — a registration against a PAID offering carries a reservation_expires_at,
// giving the registrant a real but bounded window to complete checkout before the seat releases
// automatically (server.js's reservation reaper). A free offering never sets this — nothing new
// to expire, identical to Phase 7/8 behavior.
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function computeReservationExpiry(offering) {
return offering.price_amount === null ? null : new Date(Date.now() + RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
}
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) { async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.'); if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.'); if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
@@ -235,9 +273,9 @@ async function createRegistration(pool, { offeringId, guardianId, registrantDisp
let reg; let reg;
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference) `INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`, VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11,$12) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null] [offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null, computeReservationExpiry(offering)]
); );
reg = rows[0]; reg = rows[0];
} catch (e) { } catch (e) {
@@ -346,7 +384,10 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
studentEmail: reg.is_minor ? null : reg.registrant_email, studentEmail: reg.is_minor ? null : reg.registrant_email,
studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null, studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null,
studentDisplayName: reg.registrant_display_name, studentDisplayName: reg.registrant_display_name,
}, { returnToken: true }); // admin-only surface — see enrollment.js's own comment on why this is safe here but not on the public integration endpoint }); // no returnToken here — notifyForEnrollment below issues the ONE real token that
// actually gets used; pre-issuing a second one here would just have it silently
// overwritten (found while wiring Phase 9's activation-notification call — see that
// call's own comment).
const enrollment = await enrollmentLib.createManualEnrollment(pool, { const enrollment = await enrollmentLib.createManualEnrollment(pool, {
studentId: student.student_id, programId: offering.program_id, studentId: student.student_id, programId: offering.program_id,
@@ -362,11 +403,41 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
); );
} }
// Phase 9 — the registration's own guardian (set at registration time when a guardian object
// was submitted) never used to survive conversion into the relational student_guardians model,
// which is what resolveRecipient's guardian fallback (and any future guardian-facing feature)
// actually reads. Without this, a brand-new minor student converts with no guardian link at
// all, and the activation email below has no recipient to resolve to.
if (reg.is_minor && reg.guardian_id) {
await linkStudentGuardian(pool, {
studentId: student.student_id, guardianId: reg.guardian_id,
relationship: 'guardian', isPrimaryContact: true, canReceiveCommunications: true,
});
}
await pool.query( await pool.query(
`UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`, `UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`,
[registrationId, student.student_id, enrollment.enrollment_id] [registrationId, student.student_id, enrollment.enrollment_id]
); );
return { student, enrollment };
// Phase 9 §9/§20 — the same activation-or-confirmation email every OTHER enrollment path
// (the admin manual-enroll route, the website's /api/integrations/enrollments) already
// triggers automatically. A registration-converted enrollment was the one path that never
// called this — closed here so a new camp student's real path to logging in doesn't depend
// on an admin manually relaying `student._activationToken` by hand.
const programsLib = require('./programs');
const program = await programsLib.getProgram(pool, offering.program_id);
let activationToken = null;
try {
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
studentId: student.student_id, enrollmentId: enrollment.enrollment_id, programName: program?.name || offering.name,
});
activationToken = notification?._activationToken || null;
} catch (e) {
console.error('[offerings] enrollment-activation notification failed:', e.message);
}
return { student: activationToken ? { ...student, _activationToken: activationToken } : student, enrollment };
} }
// ── Agreements ──────────────────────────────────────────────────────────────────────────────── // ── Agreements ────────────────────────────────────────────────────────────────────────────────
@@ -454,11 +525,11 @@ async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringS
} }
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference) `INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8) VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`, RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId] [offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId, computeReservationExpiry(offering)]
); );
if (rows[0]) { if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, { await notificationsLib.enqueueForRegistration(pool, {
@@ -501,6 +572,16 @@ async function computeOfferingReadiness(pool, offeringId) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id); const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 }); checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
} }
// Phase 9 §29 — a PAID offering (price_amount set) additionally needs a real price and a
// configured payment provider before it's genuinely launch-ready; a free offering never
// needs either, unchanged from Phase 8.
if (offering.price_amount !== null) {
checks.push({ key: 'price', label: 'Price configured', ready: offering.price_amount > 0 && !!offering.price_currency });
const { getPaymentProvider } = require('./payment-provider');
let providerConfigured = false;
try { const p = getPaymentProvider(); providerConfigured = p.name === 'test' || !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET); } catch { providerConfigured = false; }
checks.push({ key: 'payment_provider', label: 'Payment provider configured', ready: providerConfigured });
}
const allReady = checks.every((c) => c.ready); const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks }; return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
} }
@@ -510,7 +591,7 @@ module.exports = {
createLocation, listLocations, createLocation, listLocations,
createGuardian, linkStudentGuardian, listGuardiansForStudent, createGuardian, linkStudentGuardian, listGuardiansForStudent,
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage, createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat, createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment, createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration, createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus, addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
+114
View File
@@ -0,0 +1,114 @@
// Phase 9 §3/§4 — the payment provider abstraction. commerce.js (registration/checkout
// business logic) only ever calls the methods below — it never touches the Stripe SDK, an env
// var, or a webhook signature format directly. Swapping providers later means writing a new
// class here, not touching commerce.js/admin-commerce-routes.js/webhook-routes.js at all.
//
// PAYMENT_PROVIDER selects the implementation: 'stripe' (real, needs STRIPE_SECRET_KEY/
// STRIPE_WEBHOOK_SECRET) or 'test' (deterministic, in-process, no network, no real money —
// the default, matching EMAIL_PROVIDER's own "dev by default, real provider opt-in" precedent).
// Every test/rehearsal in this phase runs against 'test'; nothing here has ever been exercised
// against live Stripe credentials — see docs/STRIPE_INTEGRATION_RUNBOOK.md's own "pending
// live validation" note.
const PAYMENT_PROVIDER = process.env.PAYMENT_PROVIDER || 'test';
// ── Stripe adapter ────────────────────────────────────────────────────────────────────────────
class StripeProvider {
constructor() {
this.name = 'stripe';
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is not set — checkout creation and webhook verification will fail every call until it is.');
}
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || null;
if (!this.webhookSecret) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_WEBHOOK_SECRET is not set — every webhook delivery will be rejected until it is.');
}
// Lazy require — the stripe package is a real dependency (package.json), but constructing
// the client eagerly at module load would crash a 'test'-provider dev stack that never
// installs/needs a real key at all.
const Stripe = require('stripe');
this.client = secretKey ? new Stripe(secretKey) : null;
}
// amountCents/currency come from the OFFERING's own server-side price, never browser input
// (Step 5's explicit requirement) — commerce.js is the only caller, and it reads them from
// the offerings table itself.
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
if (!this.client) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing).'), { code: 'PROVIDER_NOT_CONFIGURED' });
const session = await this.client.checkout.sessions.create({
mode: 'payment',
line_items: [{ price_data: { currency, unit_amount: amountCents, product_data: { name: productName } }, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata,
});
return { checkoutUrl: session.url, providerReference: session.id };
}
// Real signature verification via Stripe's own SDK helper — never a hand-rolled HMAC compare.
// Throws on a bad/forged signature; the webhook route treats that as a clean 400, never a 500.
verifyAndParseWebhook(rawBody, signatureHeader) {
if (!this.client || !this.webhookSecret) throw Object.assign(new Error('Stripe webhook verification is not configured.'), { code: 'PROVIDER_NOT_CONFIGURED' });
const event = this.client.webhooks.constructEvent(rawBody, signatureHeader, this.webhookSecret);
return this.normalizeEvent(event);
}
// Stripe's own event vocabulary -> RootNode's narrow, provider-neutral shape. Only the event
// types this integration actually acts on are mapped to a real status; everything else comes
// back with normalizedStatus: null, which webhook-routes.js records (for audit completeness)
// but never acts on.
normalizeEvent(event) {
const obj = event.data.object;
let providerReference = obj.id;
let normalizedStatus = null;
if (event.type === 'checkout.session.completed') { normalizedStatus = 'paid'; providerReference = obj.id; }
else if (event.type === 'checkout.session.expired') { normalizedStatus = 'cancelled'; providerReference = obj.id; }
else if (event.type === 'payment_intent.payment_failed') { normalizedStatus = 'failed'; providerReference = obj.metadata?.checkout_session_id || obj.id; }
else if (event.type === 'charge.refunded') { normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded'; providerReference = obj.payment_intent || obj.id; }
return { eventId: event.id, eventType: event.type, providerReference, normalizedStatus };
}
}
// ── Test/dev adapter — deterministic, in-process, no network, no real money ─────────────────
// Every "checkout" is an immediately-resolvable fake URL; a rehearsal/test drives payment
// outcomes by POSTing a fabricated event straight at the SAME webhook route real Stripe events
// arrive on, through the SAME signature-verification interface (a static shared secret compare
// here, standing in for Stripe's HMAC check) — the actual commerce.js processing code path is
// therefore identical in both providers, only how a session/event gets created differs.
class TestProvider {
constructor() {
this.name = 'test';
this.sharedSecret = process.env.TEST_PAYMENT_WEBHOOK_SECRET || 'test-payment-secret-not-for-production';
this._sessions = new Map(); // in-process only — fine, this provider only ever exists in dev/test
}
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
const providerReference = `test_cs_${Math.random().toString(36).slice(2)}${Date.now()}`;
this._sessions.set(providerReference, { amountCents, currency, metadata });
return { checkoutUrl: `${successUrl}${successUrl.includes('?') ? '&' : '?'}test_session=${providerReference}`, providerReference };
}
// A rehearsal/test calls this directly (not a real HTTP round trip) to build the exact body a
// real client would POST to the webhook route.
buildTestEvent({ providerReference, eventType }) {
const body = JSON.stringify({ id: `evt_${Math.random().toString(36).slice(2)}${Date.now()}`, type: eventType, provider_reference: providerReference });
return { body, signature: this.sharedSecret };
}
verifyAndParseWebhook(rawBody, signatureHeader) {
if (signatureHeader !== this.sharedSecret) throw Object.assign(new Error('Invalid test webhook signature.'), { code: 'INVALID_SIGNATURE' });
const event = JSON.parse(rawBody);
const statusByType = { 'checkout.session.completed': 'paid', 'checkout.session.expired': 'cancelled', 'payment_intent.payment_failed': 'failed', 'charge.refunded': 'refunded' };
return { eventId: event.id, eventType: event.type, providerReference: event.provider_reference, normalizedStatus: statusByType[event.type] || null };
}
}
let cachedProvider = null;
function getPaymentProvider() {
if (cachedProvider) return cachedProvider;
cachedProvider = PAYMENT_PROVIDER === 'stripe' ? new StripeProvider() : new TestProvider();
return cachedProvider;
}
module.exports = { getPaymentProvider, StripeProvider, TestProvider, PAYMENT_PROVIDER };
+19
View File
@@ -35,6 +35,16 @@ const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
const app = express(); const app = express();
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1); if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
// Phase 9 §8 — the ONE route that needs the raw, unparsed request body: a payment provider's
// webhook signature (Stripe's own HMAC, or the test provider's stand-in) is computed over the
// exact bytes as sent, so express.json() must never touch this path before signature
// verification runs. Registered before the global JSON parser below so Express's normal
// path-then-registration-order middleware resolution gives this route the raw Buffer
// (`req.rawBody`) instead — every other route is completely unaffected.
app.use('/api/webhooks/payment', express.raw({ type: '*/*' }), (req, res, next) => {
req.rawBody = req.body; // a Buffer here, never parsed — webhook-routes.js reads it directly
next();
});
app.use(express.json()); app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist'); const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST)); app.use(express.static(FRONTEND_DIST));
@@ -426,6 +436,15 @@ require('./admin-offerings-routes')(app, {
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js). // boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
require('./public-offerings-routes')(app, { getPool, wrap }); require('./public-offerings-routes')(app, { getPool, wrap });
// Phase 9 — the payment provider webhook (Stripe or the test provider), and the reservation
// reaper for abandoned/unpaid registrations. See docs/PHASE9_COMMERCE_AND_LAUNCH.md.
require('./webhook-routes')(app, { getPool, recordAudit, wrap });
const commerceLib = require('./commerce');
const RESERVATION_REAPER_POLL_MS = Number(process.env.RESERVATION_REAPER_POLL_MS) || 60000;
setInterval(() => {
commerceLib.reapExpiredReservations(getPool()).catch((e) => console.error('[commerce] reservation reaper failed:', e.message));
}, RESERVATION_REAPER_POLL_MS);
// Fallback to the platform-shell SPA for any unmatched non-API route // Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html')); res.sendFile(path.join(FRONTEND_DIST, 'index.html'));