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
+23 -2
View File
@@ -183,10 +183,16 @@ async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId
});
}
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
return enqueue(pool, {
const notification = await enqueue(pool, {
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
});
// Phase 9 — the raw token is attached here (never persisted beyond the notification row's own
// transient metadata, same as always) purely so a caller that itself needs to hand the
// student a working activation link right now (offerings.js's registration-conversion flow,
// matching the trust level POST /api/admin/students already has) doesn't have to re-issue a
// second token that would silently invalidate this one.
return { ...notification, _activationToken: token };
}
async function listForStudent(pool, studentId) {
@@ -195,8 +201,23 @@ async function listForStudent(pool, studentId) {
}
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
// student_id yet at all.
// Phase 9 — a registration's full communication history spans two eras: pre-conversion
// (registration_received/confirmed/needs_action, agreement_required — keyed by registration_id,
// no student yet) and post-conversion (the activation/enrollment_confirmation email
// notifyForEnrollment sends — keyed by student_id/enrollment_id like every other enrollment,
// never by registration_id, since it isn't a registration-specific concept). Both belong in one
// admin-visible timeline for the SAME registration, so this reads the registration row itself
// to find its (possibly still-null) student_id/enrollment_id and unions both sources.
async function listForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
const { rows: regRows } = await pool.query(`SELECT student_id, enrollment_id FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
const reg = regRows[0];
const { rows } = await pool.query(
`SELECT * FROM notification_events
WHERE registration_id = $1
OR (enrollment_id IS NOT NULL AND enrollment_id = $2)
ORDER BY created_at DESC`,
[registrationId, reg?.enrollment_id || null]
);
return rows;
}
async function getById(pool, notificationId) {