diff --git a/platform/api/migrations/037_phase9_commerce.sql b/platform/api/migrations/037_phase9_commerce.sql new file mode 100644 index 0000000..af560d1 --- /dev/null +++ b/platform/api/migrations/037_phase9_commerce.sql @@ -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 +); diff --git a/platform/api/package-lock.json b/platform/api/package-lock.json index c03304c..66c483b 100644 --- a/platform/api/package-lock.json +++ b/platform/api/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "better-sqlite3": "^9.4.3", "express": "^4.19.2", - "pg": "^8.11.5" + "pg": "^8.11.5", + "stripe": "^22.6.0" } }, "node_modules/accepts": { @@ -1178,6 +1179,22 @@ "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", diff --git a/platform/api/package.json b/platform/api/package.json index f0f694a..485cec0 100644 --- a/platform/api/package.json +++ b/platform/api/package.json @@ -10,8 +10,9 @@ "bootstrap-owner": "node bootstrap-owner.js" }, "dependencies": { + "better-sqlite3": "^9.4.3", "express": "^4.19.2", "pg": "^8.11.5", - "better-sqlite3": "^9.4.3" + "stripe": "^22.6.0" } } diff --git a/platform/api/src/commerce.js b/platform/api/src/commerce.js new file mode 100644 index 0000000..53d4416 --- /dev/null +++ b/platform/api/src/commerce.js @@ -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, +}; diff --git a/platform/api/src/offerings.js b/platform/api/src/offerings.js index dab3236..b854c0c 100644 --- a/platform/api/src/offerings.js +++ b/platform/api/src/offerings.js @@ -121,15 +121,15 @@ const OFFERING_TRANSITIONS = { 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.'); 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.'); try { 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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) 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] + `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,$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, priceAmount ?? null, priceCurrency || null] ); return rows[0]; } catch (e) { @@ -141,6 +141,35 @@ async function getOffering(pool, offeringId) { const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]); 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 } = {}) { 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`, @@ -223,6 +252,15 @@ function assertRegistrationOpen(offering) { // 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 // 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 }) { 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.'); @@ -235,9 +273,9 @@ async function createRegistration(pool, { offeringId, guardianId, registrantDisp let reg; try { 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) - VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`, - [offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null] + `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,$12) RETURNING *`, + [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]; } catch (e) { @@ -346,7 +384,10 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI studentEmail: reg.is_minor ? null : reg.registrant_email, studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null, 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, { 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( `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] ); - 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 ──────────────────────────────────────────────────────────────────────────────── @@ -454,11 +525,11 @@ async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringS } try { 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) - VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8) + `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,$9) ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING 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]) { await notificationsLib.enqueueForRegistration(pool, { @@ -501,6 +572,16 @@ async function computeOfferingReadiness(pool, offeringId) { const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id); 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); return { offering_id: offeringId, ready_to_open_registration: allReady, checks }; } @@ -510,7 +591,7 @@ module.exports = { createLocation, listLocations, createGuardian, linkStudentGuardian, listGuardiansForStudent, createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage, - createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat, + createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat, createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment, createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration, addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus, diff --git a/platform/api/src/payment-provider.js b/platform/api/src/payment-provider.js new file mode 100644 index 0000000..d0acb53 --- /dev/null +++ b/platform/api/src/payment-provider.js @@ -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 }; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index 9286a58..288cecc 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -35,6 +35,16 @@ const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; const app = express(); 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()); const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../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). 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 app.get('*', (req, res) => { res.sendFile(path.join(FRONTEND_DIST, 'index.html'));