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,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: [],
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user