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