diff --git a/platform/api/src/integration-routes.js b/platform/api/src/integration-routes.js index 7f7ce6a..54617e7 100644 --- a/platform/api/src/integration-routes.js +++ b/platform/api/src/integration-routes.js @@ -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', + }); + })); }; diff --git a/platform/api/src/metrics.js b/platform/api/src/metrics.js index 9a4b362..86db121 100644 --- a/platform/api/src/metrics.js +++ b/platform/api/src/metrics.js @@ -41,6 +41,30 @@ function recordStudentActivation(result) { studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1; } +// Phase 9 §22 — commerce metrics. Every label here is a small, fixed enum (result/status/ +// provider/event_type) — never a registration_id, customer_id, payment_intent_id, or email, per +// the explicit "avoid high-cardinality" requirement. +const checkoutSessionTotals = {}; // result: 'created' | 'error' +function recordCheckoutSession(result) { checkoutSessionTotals[result] = (checkoutSessionTotals[result] || 0) + 1; } + +const paymentTotals = {}; // key: `${status}|${provider}` +function recordPayment(status, provider) { + const key = `${status}|${provider}`; + paymentTotals[key] = (paymentTotals[key] || 0) + 1; +} + +const webhookTotals = {}; // key: `${event_type}|${result}` — event_type is a small, fixed provider vocabulary, never caller-supplied free text +function recordWebhook(eventType, result) { + const key = `${eventType}|${result}`; + webhookTotals[key] = (webhookTotals[key] || 0) + 1; +} + +const conversionTotals = {}; // result: 'success' | 'error' | 'blocked' | 'skipped' +function recordConversion(result) { conversionTotals[result] = (conversionTotals[result] || 0) + 1; } + +const seatReleasedTotals = {}; // reason: 'checkout_expired' | 'reservation_expired' +function recordSeatReleased(reason) { seatReleasedTotals[reason] = (seatReleasedTotals[reason] || 0) + 1; } + // Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a // normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to // keep in sync with reality). @@ -150,10 +174,16 @@ async function renderMetrics(pool, provisioner) { labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']), line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0), line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0), + labeledCounter('rootnode_checkout_sessions_total', 'Checkout session creation attempts by result.', checkoutSessionTotals, ['result']), + labeledCounter('rootnode_payments_total', 'Payment webhook outcomes by status and provider.', paymentTotals, ['status', 'provider']), + labeledCounter('rootnode_payment_webhooks_total', 'Payment webhook deliveries by event type and processing result.', webhookTotals, ['event_type', 'result']), + labeledCounter('rootnode_payment_conversion_total', 'Registration-to-enrollment conversions triggered by a successful payment, by result.', conversionTotals, ['result']), + labeledCounter('rootnode_seats_released_total', 'Reserved seats released by reason.', seatReleasedTotals, ['reason']), ].join('\n'); } module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, recordNotificationAttempt, recordStudentActivation, renderMetrics, + recordCheckoutSession, recordPayment, recordWebhook, recordConversion, recordSeatReleased, }; diff --git a/platform/api/src/notifications.js b/platform/api/src/notifications.js index e26efdb..829f977 100644 --- a/platform/api/src/notifications.js +++ b/platform/api/src/notifications.js @@ -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) { diff --git a/platform/api/src/public-offerings-routes.js b/platform/api/src/public-offerings-routes.js index cf94b51..1a6a592 100644 --- a/platform/api/src/public-offerings-routes.js +++ b/platform/api/src/public-offerings-routes.js @@ -24,6 +24,11 @@ function toPublicOffering(row) { availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open', location_name: row.location_name || null, location_type: row.location_type || null, + // Phase 9 §13/§17 — price is the one new public field. NULL means free/no-checkout, exactly + // like the internal price_amount column — never a Stripe id, never internal commerce state. + price_amount: row.price_amount, + price_currency: row.price_currency, + requires_payment: row.price_amount !== null, }; } @@ -33,11 +38,13 @@ module.exports = function registerPublicOfferingsRoutes(app, deps) { // Only offerings a family/adult should actually be able to see: published or further along, // never draft (an admin still building it out) and never cancelled. const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress']; + const PUBLIC_COLUMNS = `o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, + o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, o.price_amount, o.price_currency, + l.name AS location_name, l.type AS location_type`; app.get('/api/public/offerings', wrap(async (req, res) => { const { rows } = await getPool().query( - `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, - o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type + `SELECT ${PUBLIC_COLUMNS} FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`, [VISIBLE_STATUSES] @@ -47,8 +54,7 @@ module.exports = function registerPublicOfferingsRoutes(app, deps) { app.get('/api/public/offerings/:slug', wrap(async (req, res) => { const { rows } = await getPool().query( - `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, - o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type + `SELECT ${PUBLIC_COLUMNS} FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id WHERE o.slug = $1 AND o.status = ANY($2)`, [req.params.slug, VISIBLE_STATUSES] diff --git a/platform/api/src/webhook-routes.js b/platform/api/src/webhook-routes.js new file mode 100644 index 0000000..c16bdac --- /dev/null +++ b/platform/api/src/webhook-routes.js @@ -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 }); + })); +};