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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user