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