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
+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 });
}));
};