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:
@@ -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