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