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 programsLib = require('./programs');
const offerings = require('./offerings');
const commerce = require('./commerce');
const { getPaymentProvider } = require('./payment-provider');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -162,4 +164,73 @@ module.exports = function registerIntegrationRoutes(app, deps) {
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',
});
}));
};