feat(webhook): add verified payment webhook route and website checkout/agreement contract

- webhook-routes.js: POST /api/webhooks/payment — signature-verified, idempotent, always 200
  once verified (a forged/invalid signature is a clean quiet 400)
- integration-routes.js: POST /api/integrations/registrations/:id/agreements (closes Phase 8's
  gap — agreement acceptance now on the public registration contract), POST .../:id/checkout
  (server-to-server, amount always server-side), GET .../:id/status (minimal safe status lookup
  for the website's success page — no PII, no amount, no provider reference)
- public-offerings-routes.js: price_amount/price_currency/requires_payment in the public serializer
- metrics.js: checkout/payment/webhook/conversion/seat-released counters, all low-cardinality
- notifications.js: listForRegistration now unions by the registration's own enrollment_id too,
  so the post-conversion activation notification shows up in the registration's communication
  history (previously only pre-conversion notifications were visible)
This commit is contained in:
2026-08-27 09:05:29 -05:00
parent b0e07bb17f
commit 16622bd9ab
5 changed files with 177 additions and 6 deletions
+71
View File
@@ -17,6 +17,8 @@ const studentsAuth = require('./students-auth');
const notifications = require('./notifications'); const notifications = require('./notifications');
const programsLib = require('./programs'); const programsLib = require('./programs');
const offerings = require('./offerings'); const offerings = require('./offerings');
const commerce = require('./commerce');
const { getPaymentProvider } = require('./payment-provider');
const metrics = require('./metrics'); const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET; const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -162,4 +164,73 @@ module.exports = function registerIntegrationRoutes(app, deps) {
created: result.created, created: result.created,
}); });
})); }));
// Phase 9 §6 — closes the one gap Phase 8 explicitly left open: agreement acceptance is now
// reachable from the public registration contract. An adult accepts as 'registrant' (their
// own student_id doesn't exist yet); a minor's guardian accepts as 'guardian', using the
// guardian_id the registration already carries from submission. Same shared-secret boundary
// as every other route in this file.
app.post('/api/integrations/registrations/:id/agreements', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const registration = await offerings.getRegistration(getPool(), req.params.id);
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
const acceptedByType = registration.is_minor ? 'guardian' : 'registrant';
try {
const acceptance = await offerings.recordAgreementAcceptance(getPool(), {
agreementId: req.body?.agreement_id, acceptedByType,
acceptedByGuardianId: registration.is_minor ? registration.guardian_id : undefined,
registrationId: registration.registration_id, ipAddress: req.ip,
});
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'agreement_accepted', resourceType: 'registration', resourceId: registration.registration_id, result: 'success', sourceIp: req.ip, metadata: { agreement_id: req.body?.agreement_id } });
res.status(201).json({ accepted: true, agreement_id: acceptance.agreement_id, accepted_version: acceptance.accepted_version });
} catch (e) {
if (e.code) return res.status(e.code === 'AGREEMENT_NOT_FOUND' ? 404 : 400).json({ error: e.code, message: e.message });
throw e;
}
}));
// Phase 9 §5 — server-to-server checkout creation. The website's backend calls this with a
// registration_id it already has (from the registration-submission response above); the
// browser never talks to this endpoint directly, and never supplies an amount/currency —
// both always come from the offering's own server-side price (commerce.js's own
// createCheckoutSession, Step 5's explicit "browser cannot choose price" requirement).
app.post('/api/integrations/registrations/:id/checkout', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const { success_url, cancel_url } = req.body || {};
if (!success_url || !cancel_url) return res.status(400).json({ error: 'VALIDATION', message: 'success_url and cancel_url are required.' });
try {
const provider = getPaymentProvider();
const result = await commerce.createCheckoutSession(getPool(), provider, req.params.id, { successUrl: success_url, cancelUrl: cancel_url });
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'checkout_created', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { transaction_id: result.transactionId } });
res.status(201).json({ checkout_url: result.checkoutUrl, transaction_id: result.transactionId });
} catch (e) {
if (e.code) {
const status = { NOT_FOUND: 404, OFFERING_NOT_FOUND: 404, NO_PRICE_CONFIGURED: 409, INVALID_STATE: 409, AGREEMENTS_INCOMPLETE: 409, OFFERING_NOT_OPEN: 409, OFFERING_CANCELLED: 409, OFFERING_COMPLETED: 409, OFFERING_FULL: 409, REGISTRATION_NOT_YET_OPEN: 409, REGISTRATION_CLOSED: 409, PROVIDER_NOT_CONFIGURED: 503 }[e.code] || 500;
return res.status(status).json({ error: e.code, message: e.message });
}
throw e;
}
}));
// Phase 9 §16/§17 — a safe status lookup for the website's post-checkout success page. NEVER
// derive "paid" from a URL param the browser controls (a returning `?paid=true` proves
// nothing) — the website's backend polls this instead, server-to-server, same shared secret,
// until it sees a terminal state. Deliberately minimal: no PII, no payment amount, no provider
// reference, nothing an admin view would show — just enough to render "processing" /
// "confirmed" / "action needed" on the success page.
app.get('/api/integrations/registrations/:id/status', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const registration = await offerings.getRegistration(getPool(), req.params.id);
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
const transactions = await commerce.listTransactionsForRegistration(getPool(), registration.registration_id);
res.json({
registration_id: registration.registration_id,
status: registration.status,
payment_status: transactions[0]?.status || null,
enrollment_activated: registration.status === 'enrollment_activated',
});
}));
}; };
+30
View File
@@ -41,6 +41,30 @@ function recordStudentActivation(result) {
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1; studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
} }
// Phase 9 §22 — commerce metrics. Every label here is a small, fixed enum (result/status/
// provider/event_type) — never a registration_id, customer_id, payment_intent_id, or email, per
// the explicit "avoid high-cardinality" requirement.
const checkoutSessionTotals = {}; // result: 'created' | 'error'
function recordCheckoutSession(result) { checkoutSessionTotals[result] = (checkoutSessionTotals[result] || 0) + 1; }
const paymentTotals = {}; // key: `${status}|${provider}`
function recordPayment(status, provider) {
const key = `${status}|${provider}`;
paymentTotals[key] = (paymentTotals[key] || 0) + 1;
}
const webhookTotals = {}; // key: `${event_type}|${result}` — event_type is a small, fixed provider vocabulary, never caller-supplied free text
function recordWebhook(eventType, result) {
const key = `${eventType}|${result}`;
webhookTotals[key] = (webhookTotals[key] || 0) + 1;
}
const conversionTotals = {}; // result: 'success' | 'error' | 'blocked' | 'skipped'
function recordConversion(result) { conversionTotals[result] = (conversionTotals[result] || 0) + 1; }
const seatReleasedTotals = {}; // reason: 'checkout_expired' | 'reservation_expired'
function recordSeatReleased(reason) { seatReleasedTotals[reason] = (seatReleasedTotals[reason] || 0) + 1; }
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a // Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to // normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
// keep in sync with reality). // keep in sync with reality).
@@ -150,10 +174,16 @@ async function renderMetrics(pool, provisioner) {
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']), labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']),
line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0), line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0),
line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0), line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
labeledCounter('rootnode_checkout_sessions_total', 'Checkout session creation attempts by result.', checkoutSessionTotals, ['result']),
labeledCounter('rootnode_payments_total', 'Payment webhook outcomes by status and provider.', paymentTotals, ['status', 'provider']),
labeledCounter('rootnode_payment_webhooks_total', 'Payment webhook deliveries by event type and processing result.', webhookTotals, ['event_type', 'result']),
labeledCounter('rootnode_payment_conversion_total', 'Registration-to-enrollment conversions triggered by a successful payment, by result.', conversionTotals, ['result']),
labeledCounter('rootnode_seats_released_total', 'Reserved seats released by reason.', seatReleasedTotals, ['reason']),
].join('\n'); ].join('\n');
} }
module.exports = { module.exports = {
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
recordNotificationAttempt, recordStudentActivation, renderMetrics, recordNotificationAttempt, recordStudentActivation, renderMetrics,
recordCheckoutSession, recordPayment, recordWebhook, recordConversion, recordSeatReleased,
}; };
+23 -2
View File
@@ -183,10 +183,16 @@ async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId
}); });
} }
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
return enqueue(pool, { const notification = await enqueue(pool, {
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token, studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend }, metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
}); });
// Phase 9 — the raw token is attached here (never persisted beyond the notification row's own
// transient metadata, same as always) purely so a caller that itself needs to hand the
// student a working activation link right now (offerings.js's registration-conversion flow,
// matching the trust level POST /api/admin/students already has) doesn't have to re-issue a
// second token that would silently invalidate this one.
return { ...notification, _activationToken: token };
} }
async function listForStudent(pool, studentId) { async function listForStudent(pool, studentId) {
@@ -195,8 +201,23 @@ async function listForStudent(pool, studentId) {
} }
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no // Phase 8 — every notification tied to a registration, including pre-conversion ones with no
// student_id yet at all. // student_id yet at all.
// Phase 9 — a registration's full communication history spans two eras: pre-conversion
// (registration_received/confirmed/needs_action, agreement_required — keyed by registration_id,
// no student yet) and post-conversion (the activation/enrollment_confirmation email
// notifyForEnrollment sends — keyed by student_id/enrollment_id like every other enrollment,
// never by registration_id, since it isn't a registration-specific concept). Both belong in one
// admin-visible timeline for the SAME registration, so this reads the registration row itself
// to find its (possibly still-null) student_id/enrollment_id and unions both sources.
async function listForRegistration(pool, registrationId) { async function listForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]); const { rows: regRows } = await pool.query(`SELECT student_id, enrollment_id FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
const reg = regRows[0];
const { rows } = await pool.query(
`SELECT * FROM notification_events
WHERE registration_id = $1
OR (enrollment_id IS NOT NULL AND enrollment_id = $2)
ORDER BY created_at DESC`,
[registrationId, reg?.enrollment_id || null]
);
return rows; return rows;
} }
async function getById(pool, notificationId) { async function getById(pool, notificationId) {
+10 -4
View File
@@ -24,6 +24,11 @@ function toPublicOffering(row) {
availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open', availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open',
location_name: row.location_name || null, location_name: row.location_name || null,
location_type: row.location_type || null, location_type: row.location_type || null,
// Phase 9 §13/§17 — price is the one new public field. NULL means free/no-checkout, exactly
// like the internal price_amount column — never a Stripe id, never internal commerce state.
price_amount: row.price_amount,
price_currency: row.price_currency,
requires_payment: row.price_amount !== null,
}; };
} }
@@ -33,11 +38,13 @@ module.exports = function registerPublicOfferingsRoutes(app, deps) {
// Only offerings a family/adult should actually be able to see: published or further along, // Only offerings a family/adult should actually be able to see: published or further along,
// never draft (an admin still building it out) and never cancelled. // never draft (an admin still building it out) and never cancelled.
const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress']; const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress'];
const PUBLIC_COLUMNS = `o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, o.price_amount, o.price_currency,
l.name AS location_name, l.type AS location_type`;
app.get('/api/public/offerings', wrap(async (req, res) => { app.get('/api/public/offerings', wrap(async (req, res) => {
const { rows } = await getPool().query( const { rows } = await getPool().query(
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, `SELECT ${PUBLIC_COLUMNS}
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`, WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`,
[VISIBLE_STATUSES] [VISIBLE_STATUSES]
@@ -47,8 +54,7 @@ module.exports = function registerPublicOfferingsRoutes(app, deps) {
app.get('/api/public/offerings/:slug', wrap(async (req, res) => { app.get('/api/public/offerings/:slug', wrap(async (req, res) => {
const { rows } = await getPool().query( const { rows } = await getPool().query(
`SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, `SELECT ${PUBLIC_COLUMNS}
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.slug = $1 AND o.status = ANY($2)`, WHERE o.slug = $1 AND o.status = ANY($2)`,
[req.params.slug, VISIBLE_STATUSES] [req.params.slug, VISIBLE_STATUSES]
+43
View File
@@ -0,0 +1,43 @@
// Phase 9 §8 — the verified payment-provider webhook endpoint. Server-to-server only (Stripe's
// own servers, or a test/rehearsal script standing in for them) — no session of any kind is
// checked or accepted here, the SAME trust boundary shape as /api/integrations/*, but the
// authentication mechanism is different on purpose: a provider webhook proves itself via a
// cryptographic signature over the exact raw body, not a static shared-secret header, which is
// why this route needs the raw (unparsed) request body — see server.js's own route-registration
// comment for how that's wired without breaking every other route's normal JSON body parsing.
const commerce = require('./commerce');
const { getPaymentProvider } = require('./payment-provider');
const studentsAuth = require('./students-auth');
module.exports = function registerWebhookRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
// rawBody is attached by server.js's raw-body middleware, scoped to only this path — every
// other route keeps using express.json() as before.
app.post('/api/webhooks/payment', wrap(async (req, res) => {
const provider = getPaymentProvider();
const signature = req.get('Stripe-Signature') || req.get('X-Test-Payment-Signature');
let normalizedEvent;
try {
normalizedEvent = provider.verifyAndParseWebhook(req.rawBody, signature);
} catch (e) {
// A forged/invalid signature is a clean, quiet 400 — never a 500, never a hint about
// WHY verification failed (timing/oracle hygiene, matching this codebase's existing
// anti-enumeration precedent elsewhere).
return res.status(400).json({ error: 'invalid_signature' });
}
const result = await commerce.processWebhookEvent(getPool(), studentsAuth, provider, normalizedEvent);
await recordAudit(getPool(), {
actorType: 'integration', actorId: `payment_webhook:${provider.name}`, action: `payment_webhook_${result.result}`,
resourceType: 'payment_webhook_event', resourceId: normalizedEvent.eventId, result: 'success', sourceIp: req.ip,
metadata: { event_type: normalizedEvent.eventType, normalized_status: normalizedEvent.normalizedStatus || null },
});
// Always 200 once signature verification passes — Stripe retries on anything else, and a
// duplicate/ignored/error-but-already-recorded event is not something we want retried
// forever; the audit trail and payment_webhook_events row are the real record of what
// happened, not the HTTP status.
res.json({ received: true, result: result.result });
}));
};