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
|
||||||
|
);
|
||||||
Generated
+18
-1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user