6 Commits
Author SHA1 Message Date
ced4568 80a5f6e457 docs(phase9): document commerce integration and Winter Break launch readiness
New: PHASE9_COMMERCE_AND_LAUNCH.md, WEBSITE_REGISTRATION_AND_CHECKOUT.md,
STRIPE_INTEGRATION_RUNBOOK.md, WINTER_BREAK_LAUNCH_CHECKLIST.md. Updated:
ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (commerce boundary + PHASE 9 BUILT rows),
OFFERING_AND_REGISTRATION_MODEL.md (pricing, the payment/commerce boundary, three-status
principle), WEBSITE_ENROLLMENT_INTEGRATION.md (price fields, real agreement/checkout/webhook
sections replacing Phase 8 placeholders), WINTER_BREAK_CAMP_OPERATIONS.md (real payment step
replacing the manual placeholder).
2026-08-27 09:06:04 -05:00
ced4568 281d60bc2d test(camp): add Winter Break commerce rehearsal
13/13 against a real, freshly-built stack, PAYMENT_PROVIDER=test (no live money): admin
configuration including price/payment-provider readiness, minor + adult registration through
real checkout and a real webhook call, duplicate-webhook idempotency, failed-payment seat
retention, abandoned-checkout seat release, a last-seat checkout race, a real classroom session,
and a real Lab task completed by the newly-activated minor student.
2026-08-27 09:05:54 -05:00
ced4568 8b3e56e402 test(phase9): add commerce integration acceptance suite
19/19: pricing in the public serializer, checkout gated on agreements, server-authoritative
amount, webhook signature verification and idempotency, payment success/failure/abandonment,
last-seat checkout race, reservation reaper, manual refund with no cascade, minor guardian
routing, admin/RBAC payment-data restrictions, audit/metric hygiene, the status-lookup endpoint,
and confirmation that ROOT_ V1/free-offering/classroom flows are unaffected.
2026-08-27 09:05:46 -05:00
ced4568 f0dde9b84a feat(admin): add offering pricing and payment/refund UI
- admin-offerings-routes.js: PATCH /api/admin/offerings/:id (allowlisted fields), GET
  /api/admin/registrations/:id/payments, POST /api/admin/payments/:id/refund
- frontend: Pricing card on offering detail, Payments card (transaction history + record-refund
  action) on registration detail — admin/owner-gated, reusing existing admin UI conventions
2026-08-27 09:05:36 -05:00
ced4568 16622bd9ab 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)
2026-08-27 09:05:29 -05:00
ced4568 b0e07bb17f feat(commerce): add payment-provider abstraction, commerce layer, and pricing schema
- migration 037: offerings.price_amount/price_currency, offering_registrations.reservation_expires_at,
  payment_transactions, payment_webhook_events (webhook_event_id as real PK — the idempotency mechanism)
- payment-provider.js: StripeProvider (real SDK, HMAC webhook verification) + TestProvider
  (deterministic, in-process, no network) behind one interface, selected via PAYMENT_PROVIDER
- commerce.js: createCheckoutSession (server-side price, never caller-supplied), processWebhookEvent
  (idempotent), handlePaymentSuccess/Failure/CheckoutAbandoned/Refund, reservation reaper support
- offerings.js: computeReservationExpiry, updateOffering (allowlisted PATCH), readiness gained
  price/payment_provider checks, convertRegistrationToEnrollment now fires the activation email
  every other enrollment path already triggers, and links the registration's guardian_id into
  student_guardians on conversion (real bug: a converted minor had no guardian link, so the new
  activation email had nowhere to resolve a recipient to)
- server.js: raw-body middleware scoped to /api/webhooks/payment, reservation-reaper poller
2026-08-27 09:05:19 -05:00
27 changed files with 2305 additions and 99 deletions
+61 -7
View File
@@ -1,9 +1,10 @@
# Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings,
capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/
034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql`
for the exact DDL and `platform/api/src/offerings.js` for the implementation.
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8, extended again in Phase 9) — the schema and
lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See
`platform/api/migrations/034_phase7_offerings_registrations_agreements.sql`,
`035_phase8_enrollment_operations.sql`, and `037_phase9_commerce.sql` for the exact DDL and
`platform/api/src/offerings.js`/`commerce.js` for the implementation.
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
full/window-closed offerings reject registration attempts with distinct error codes — see
@@ -14,6 +15,14 @@ registration integration (`POST /api/integrations/registrations`) is live — se
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
**Updated for Phase 9**: an offering can carry a real price (`price_amount`/`price_currency`); a
paid offering's registration reserves its seat with a bounded expiry
(`reservation_expires_at`) instead of holding it forever; agreement acceptance is now reachable
from the public registration contract (closing Phase 8's one open gap); and a verified payment
webhook — never a browser redirect — is what actually confirms a registration and activates the
enrollment. See "Pricing" and "Payment and the commerce boundary" below, and `docs/
PHASE9_COMMERCE_AND_LAUNCH.md` for the full phase report.
## Offering lifecycle (server-defined transitions only)
```
@@ -77,6 +86,16 @@ Not every offering needs every stage — a simple self-paced offering can go `st
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
**Phase 9** — for a PRICED offering, a verified `checkout.session.completed` (or the test
provider's equivalent) webhook event is what actually walks the registration through whichever
of `submitted`/`agreements_pending`/`reviewed`/`payment_pending` it's currently sitting in,
straight to `confirmed`, using the exact same `transitionRegistration()` table every other path
already enforces — never a shortcut that skips a real state. A failed payment does NOT cancel
the registration or release its seat (the registrant can retry checkout against the same
reservation); only an abandoned/expired checkout session, or the reservation reaper timing out an
un-paid reservation, cancels the registration and releases the seat. See "Payment and the
commerce boundary" below.
A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created
student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real
academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/
@@ -129,8 +148,43 @@ For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points a
up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced`
offering simply has `cohort_id = NULL`; there is no cohort to join.
## Pricing (Phase 9)
`offerings.price_amount` (integer minor units — cents, matching Stripe's own convention;
`NULL` = free/no checkout) and `offerings.price_currency` (default `usd`), both editable via
`PATCH /api/admin/offerings/:id`. A registration's checkout amount always comes from the
offering's OWN server-side price at checkout-creation time — never from anything the browser or
website backend supplies (`commerce.createCheckoutSession()` reads it directly from the
`offerings` row; there is no `amount` field anywhere in the checkout request the website sends).
`computeOfferingReadiness()` gained matching `price` and `payment_provider` checks — a priced
offering isn't launch-ready until a real price is set and the configured payment provider is
actually usable (the test provider always is; Stripe requires both `STRIPE_SECRET_KEY` and
`STRIPE_WEBHOOK_SECRET` to be set).
## Payment and the commerce boundary (Phase 9)
**Payment is a narrow, provider-agnostic layer bolted onto the existing registration model, not
a redesign of it.** `payment_transactions` (one row per checkout attempt: amount/currency
snapshotted at creation, provider, provider_reference, status) and `payment_webhook_events` (one
row per provider event id — the ENTIRE webhook idempotency mechanism, a real unique primary key,
not a "check then insert") are the only two new tables. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md`
for the payment-provider abstraction, the webhook contract, and the reservation-reaper design in
full, and `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own checkout contract.
**Three separately-tracked statuses, never auto-cascading into each other:** the registration's
own `status` (this document), the enrollment's own `status` (unchanged, `enrollment.js`), and a
payment transaction's own `status` (`payment_transactions.status`). A refund updates ONLY the
transaction's status — it never automatically cancels the registration or the enrollment; an
admin decides separately whether a refund also means "this student is no longer enrolled,"
because the two are genuinely different real-world decisions (a partial-camp refund for a missed
day is not the same decision as pulling a student's access).
## What was deliberately not built
Payment processing, a public-facing registration form, pricing display, and subscription
billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for
future descriptive pricing/marketing fields, but nothing writes payment data into it.
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
plans, complex tax automation, and accounting-system (QuickBooks etc.) integration — all
explicitly out of scope, per Phase 9's own authorization. A waitlist was considered and
deliberately deferred (see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`'s "What was deliberately not
built" section for the reasoning). `offerings.metadata` (JSONB) still has room for future
descriptive marketing fields, but nothing writes payment data into it — payment data lives only
in `payment_transactions`.
+276
View File
@@ -0,0 +1,276 @@
# Phase 9 — Commerce Integration, Website Registration & Winter Break Launch
Phase 9's mission: turn Phase 8's operable-but-unpaid enrollment system into a REAL commerce
pipeline — offering price → website registration → agreement acceptance (closing Phase 8's one
open gap) → hosted checkout → a verified, idempotent payment webhook → registration confirmation
→ enrollment activation — without ever trusting a browser for price, payment status, or
agreement acceptance, and without touching a single payment card number anywhere in this
codebase.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 8's
`788fb197e0a270423c1ff55829dc1d5dc380591b`.
## What was built
- **Migration 037** — `offerings.price_amount`/`price_currency` (nonnegative CHECK),
`offering_registrations.reservation_expires_at`, and two new tables:
`payment_transactions` (one row per checkout attempt — amount/currency snapshotted at
creation, provider, provider_reference with a unique partial index, status) and
`payment_webhook_events` (one row per provider event id, a real PK — the entire webhook
idempotency mechanism, never a "check then insert").
- **`payment-provider.js`** (new) — the payment-provider abstraction. `StripeProvider` (real
Stripe SDK, lazy-constructed so a test-provider dev stack never needs the real package
configured, real HMAC webhook verification via Stripe's own SDK helper, a narrow
`normalizeEvent()` mapping Stripe's event vocabulary to `{eventId, eventType,
providerReference, normalizedStatus}`) and `TestProvider` (deterministic, in-process, no
network, no real money — the default, matching `EMAIL_PROVIDER`'s existing "dev by default,
real provider opt-in" precedent). `commerce.js` and everything above it call the SAME methods
regardless of which is active — swapping providers is an environment variable, not a code
change.
- **`commerce.js`** (new) — the business-logic layer combining `offerings.js` and
`payment-provider.js`: `createCheckoutSession()` (validates state/agreements, snapshots the
offering's own price, never a caller-supplied amount), `processWebhookEvent()` (the idempotent
webhook entry point), `handlePaymentSuccess/Failure/CheckoutAbandoned/Refund()`,
`recordManualRefund()`, `reapExpiredReservations()`.
- **`webhook-routes.js`** (new) — `POST /api/webhooks/payment`. Reads the raw request body
(Express raw-body middleware scoped to only this one path, registered before the global JSON
parser in `server.js`), checks `Stripe-Signature`/`X-Test-Payment-Signature`, treats a bad
signature as a clean, quiet `400` (never a 500, never a hint about why), always responds `200`
once verification passes (a duplicate/ignored event is recorded, not retried forever).
- **`integration-routes.js` extended** — `POST /api/integrations/registrations/:id/agreements`
(closes Phase 8's explicit gap — agreement acceptance is now on the public registration
contract; who accepts is server-derived from the registration's own `is_minor`, never
caller-supplied), `POST /api/integrations/registrations/:id/checkout` (server-to-server only,
the amount always comes from the offering's own price), and `GET
/api/integrations/registrations/:id/status` (a deliberately minimal, non-admin status lookup
for the website's success page — no PII, no amount, no provider reference — see "Real bugs
found" below for why this exists).
- **`offerings.js` extended** — `reservation_expires_at` computed only for priced offerings
(`computeReservationExpiry()`); `OFFERING_EDITABLE_FIELDS`/`updateOffering()` (dynamic,
allowlisted `PATCH`, never touches status/slug/program_id); `computeOfferingReadiness()`
gained `price` and `payment_provider` checks; `convertRegistrationToEnrollment()` now fires the
same activation-or-confirmation email every other enrollment path already triggers (closing a
second real gap — see below).
- **Admin UI** — a Pricing card on the offering detail view (set/clear price), a Payments card on
the registration detail view (transaction history, a "Record refund" action for paid
transactions), both admin/owner-gated, both reusing the existing `Admin.module.css`/`adminApi`
conventions exactly.
- **Reservation reaper** — a `setInterval` poller (`server.js`) releasing any paid-offering
reservation whose `reservation_expires_at` has passed and which never reached a terminal state
(`enrollment_activated`/`cancelled`/`confirmed` excluded).
- **Prometheus metrics** — `rootnode_checkout_sessions_total`, `rootnode_payments_total`,
`rootnode_payment_webhooks_total`, `rootnode_payment_conversion_total`,
`rootnode_seats_released_total` — every label a small fixed enum (result/status/provider/
event_type/reason), never a registration_id, customer_id, or payment_intent_id.
## Three real bugs found and fixed during this phase's own testing (before any commit)
1. **Duplicate `registration_confirmed` notification.** `handlePaymentSuccess` originally fired
an explicit `registration_confirmed` notification AFTER `transitionRegistration('confirmed')`
already fires one internally — found via direct smoke-test output showing the notification
twice on one registration. Fixed by deleting the redundant explicit call.
2. **A converted registration never sent an activation email.** `convertRegistrationToEnrollment`
created the real student/enrollment/cohort rows but never called `notifyForEnrollment` — every
OTHER enrollment path (the admin manual-enroll route, `/api/integrations/enrollments`) already
does. Fixed by adding the call — which then surfaced bug 3 below, found immediately by the
Winter Break commerce rehearsal (not by the narrower e2e suite, which happened not to exercise
a brand-new minor-guardian conversion through this exact path):
3. **A converted minor student had no real guardian link.** `convertRegistrationToEnrollment`
creates the new student and enrollment but never promoted the registration's own `guardian_id`
into the relational `student_guardians` table — so `notifications.js`'s guardian-recipient
fallback (`resolveRecipient()`) had nothing to resolve to, and the new activation email failed
with `no_recipient_address`. Fixed by calling the existing `linkStudentGuardian()` helper
(already used elsewhere for admin-managed guardian links, never previously wired into
conversion) whenever `reg.is_minor && reg.guardian_id`. This is genuine plumbing, not a
business decision — a minor student converting through a paid registration now has the exact
same real guardian relationship a student created any other way already has.
Found via the Winter Break commerce rehearsal specifically, not the narrower e2e suite — the
rehearsal's own multi-step, real-webhook, real-notification-worker structure exercises the exact
timing and data shape a real minor registration produces, where a synthetic unit-style test can
accidentally supply a field (like `registrant_email`) the real website contract never actually
sends for a minor.
## Design decisions worth stating explicitly
- **Seat reservation timing unchanged from Phase 7/8**: a seat is still reserved AT
REGISTRATION, not at checkout. Phase 9 layers a bounded, configurable expiration
(`reservation_expires_at`, paid offerings only) on top rather than redesigning the reservation
model — a registrant who starts checkout keeps their seat for
`REGISTRATION_RESERVATION_TIMEOUT_MINUTES` (default 30) even if they never complete payment,
released either by an abandoned-checkout webhook event or the reaper.
- **A failed payment does NOT release the seat.** The registrant can retry checkout against the
same reservation. Only an abandoned/expired checkout session or reaper timeout releases it.
Proved directly in both `e2e-phase9.js` and the commerce rehearsal, as two genuinely different
code paths (`handlePaymentFailure` vs. `handleCheckoutAbandoned`/the reaper), not one path with
a flag.
- **Three separately-tracked statuses, never auto-cascading**: registration status
(`offering_registrations.status`), enrollment status (`enrollments.status`), payment status
(`payment_transactions.status`). A manual refund updates ONLY the transaction's status —
proved directly by querying both other statuses unchanged after a refund in `e2e-phase9.js`,
not just asserted by comment.
- **No waitlist was built.** Considered and deliberately deferred — the existing
`cancelled`-releases-a-seat mechanism, now paired with the reservation reaper's automatic
release of abandoned checkouts, covers the real near-term operational need (a freed seat
reopens registration automatically) without adding a new status-machine branch this phase has
no concrete evidence would be used for a single-camp launch. Revisit once real Winter Break
operational experience exists.
## Testing
`platform/test/e2e-phase9.js` — 19/19 passing (fresh stack): offering pricing in the public
serializer (no internal field leakage), checkout blocked until agreements are accepted,
registration rejected on draft/closed/full offerings, the server-authoritative amount (a
caller-supplied `amount` is silently ignored), webhook signature required/rejected-if-invalid/
idempotent-on-replay, payment success driving real conversion and activation with exactly one
`registration_confirmed` notification (no duplicate), a failed payment leaving the seat intact
and the registration retriable, a last-seat concurrent checkout race (`[201,409]`), the
reservation reaper releasing an expired unpaid reservation, a manual refund with no cascade into
registration/enrollment status, a minor payment confirmation routing to the guardian (never
writing to `students.email`), the admin payment view (amount/status/provider, never card data),
RBAC (a plain instructor denied payment data, the public API never exposing it), audit/metric
hygiene (no `sk_`/`whsec_`/fake-card patterns, no registration_id as a metric label), the new
website status-lookup endpoint's minimal safe shape, and confirmation that ROOT_ V1 self-paced
enrollment, a free (Phase-8-style) offering, and classroom/cohort flows are all unaffected.
## Winter Break commercial-path rehearsal
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` — **13/13 passing** against a real,
freshly-built stack, `PAYMENT_PROVIDER=test` (no live money, no Stripe account needed): admin
configures a real priced offering (location, capacity, instructor, required agreement, price,
payment provider) and readiness honestly reports `ready_to_open_registration: false` (dates/
registration-window are deliberately unset — a real business decision this phase does not
invent) while every check it DID configure shows ready; admin opens registration; a minor
registers via the website integration, checkout is correctly blocked before the agreement is
accepted, the guardian accepts it, checkout is created; the test payment provider confirms
payment via a REAL webhook call — registration converts, the real activation token is retrieved
via the same dev-preview mechanism `e2e-phase5c.js` already established (never a raw-token
shortcut), and the minor's account activates; the exact same webhook event replayed (a real
Stripe retry scenario) changes nothing further; an adult registers, pays, and converts
independently with no guardian involved; a failed payment leaves a retriable registration without
consuming a phantom extra seat; an abandoned checkout (expired session) releases the seat; two
students genuinely compete for the final seat of a paid offering and exactly one wins; the
instructor starts a real classroom session and Prepares Labs; the minor student joins, completes
real orientation plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab
container, and the instructor sees the real roster, payment, and progress state for the camp.
## Regression
Fresh-stack reruns after every fix: `e2e-phase8.js` 16/16, `e2e-phase7.js` 17/17, `e2e-phase6f.js`
17/17, `e2e-phase5c.js` 29/29, `e2e-phase5b.js` 32/32 — every existing flow this phase's changes
touch (`offerings.js`, `notifications.js`) reconfirmed byte-for-byte unaffected. `e2e-phase5a.js`
not independently rerun, per the same established convention as every prior phase's own doc — its
checks are embedded in and reconfirmed by 5B's/5C's own "Phase 5A ... still works unmodified"
assertions, and no code Phase 5A itself owns was touched. Course-content validator
(`infra/scripts/validate-course-content.js`) — clean, 0 errors, unaffected by this phase.
Migration idempotency — verified by restarting the API container against an already-migrated
database (no `down -v`) and confirming a clean startup with no migration errors, in addition to
every fresh `down -v`/`up --build` cycle across this phase's own testing applying all 37
migrations cleanly from empty every time.
## Frontend build
`npm run build` clean — the new Pricing/Payments admin UI cards compile without warning; the
only new warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from
every prior phase.
## Real Lab result
The Winter Break commerce rehearsal drives a real disposable Lab container through a genuine
`linux-inspect-hardware` scenario setup/validate cycle, reached this time through the full,
real commerce path (checkout → webhook → conversion → activation → classroom join) rather than a
simulated payment step — confirmed passing for real, not simulated.
## Security/privacy findings
- No payment-card data exists anywhere in this codebase — not in a request field, not in a
database column, not in a log line, not in an audit-event metadata blob. Stripe's hosted
Checkout page is the only thing that ever sees a real card number.
- The price is read server-side from the offering at checkout-creation time; a caller-supplied
`amount` field (tested directly) is silently ignored — a browser cannot choose its own price.
- The webhook route requires a verified signature on every request; an invalid/forged signature
is rejected with a generic `400`, never a 500 and never a hint about why verification failed
(matching this codebase's existing anti-enumeration precedent).
- The webhook is idempotent via a real database primary key on the provider's own event id — a
retried delivery (Stripe's own retry behavior on anything but a `200`) can never double-process.
- Admin payment views require admin/owner role; a plain instructor is denied (`403`, tested
directly); the public offerings API never exposes payment/transaction data at all.
- Metrics and audit events use only small, fixed-enum labels — no registration_id, customer_id,
payment_intent_id, or email ever appears as a metric label or in audit metadata (tested
directly via regex against both).
- Cross-student/guardian isolation holds: a minor's payment confirmation routes the activation
email to the guardian, never writing the guardian's email into the student's own identity
record — the same invariant Phase 8 already established, reconfirmed under the new payment
path.
## Legal-review requirements — unchanged, restated
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
phase's work: the actual legal text of any participation/media/liability agreement,
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
emergency-contact fields need additional encryption at rest. Nothing about accepting payment
changes this list.
## What was deliberately NOT built (explicit, per this phase's own authorization)
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
plans, complex tax automation, accounting-system (QuickBooks etc.) integration, a Parent Portal
or guardian login, a CRM, marketing email/SMS, certificates, INIT_/COMPILE_ curriculum, ROOT
08/11, production deployment, a mobile app, and an AI tutor — none of these were touched, added,
or scaffolded "for later." A waitlist was considered and deliberately deferred (see "Design
decisions" above).
## Documentation
New: `PHASE9_COMMERCE_AND_LAUNCH.md` (this document), `WEBSITE_REGISTRATION_AND_CHECKOUT.md`,
`STRIPE_INTEGRATION_RUNBOOK.md`, `WINTER_BREAK_LAUNCH_CHECKLIST.md`. Updated:
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (commerce boundary + PHASE 9 BUILT rows),
`OFFERING_AND_REGISTRATION_MODEL.md` (pricing, the payment/commerce boundary, the three-status
principle, the updated "not built" list), `WEBSITE_ENROLLMENT_INTEGRATION.md` (price/availability
fields, the real agreement/checkout/webhook/confirmation sections replacing Phase 8's
placeholders), `WINTER_BREAK_CAMP_OPERATIONS.md` (the real payment step replacing the old manual
placeholder, updated blockers), `platform/README.md` (test list). Reviewed, no change needed:
`docs/ENGINEERING_SOP.md` (its own numbered-section convention has not been extended past Phase
6B for any prior phase either — Phase 7 and 8 made the same call; its Roadmap Status table
doesn't itemize commerce as a tracked line item to begin with).
## Known limitations
- The website's own registration/checkout UI does not exist yet in this repository
(rootnodeacademy.com is built separately, not RootNode-owned in this repo) — the contract is
ready and tested, nobody has built the website-side form against it yet.
- No real Stripe credentials exist — the entire commerce pipeline is built and tested against the
deterministic test provider, which exercises the exact same `commerce.js`/webhook-route code
path real Stripe traffic would, but has never made a real network call to Stripe's API or
received a real Stripe-signed webhook. See `docs/STRIPE_INTEGRATION_RUNBOOK.md`.
- No waitlist (deliberate, see "Design decisions" above).
- `registration_needs_action` still has no automatic trigger, unchanged from Phase 8 — still a
deliberate admin action, not something a failed payment forces (a failed payment DOES send this
notification automatically, but broader "needs action" cases remain admin-initiated).
## Remaining blockers before opening Winter Break registration to the public
See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full, itemized list. In summary: real dates/
venue/capacity/price (business decisions), the agreement's actual legal text (legal review), real
Stripe credentials (external dependency), and the website's own registration form (separate,
not-yet-built repo). None of these are platform readiness gaps — the platform is ready to operate
the full commerce flow the moment those four external decisions/assets exist.
## Commits
See `git log` for this phase's exact commit sequence (migrations/payment-provider/commerce,
integration routes, admin UI, tests + rehearsal, docs) — grouped per
`docs/ENGINEERING_SOP.md`'s existing convention.
## Recommended next phase
Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATION_RUNBOOK.md`'s
"Going live" section); the actual website-side registration/checkout form; the agreement's legal
text once reviewed; and, informed by real Winter Break Camp commercial operation, whether a
waitlist is actually worth building.
---
## PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING
@@ -98,15 +98,19 @@ unchanged). A registration becomes a real enrollment only once `confirmed`, via
`agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal
text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
## Commerce boundary — unchanged, extended in documentation only
## Commerce boundary — real as of Phase 9
The existing boundary (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) stands: the website owns
marketing/pricing/checkout, RootNode's platform owns everything after a verified commerce event.
Phase 7 adds a documented FUTURE flow (website → offering selection → registration → checkout →
verified commerce event → registration confirmed → enrollment activated) without implementing
any payment code. **BUILT NOW**: the existing `/api/integrations/enrollments` endpoint,
unchanged. **PHASE 7 BUILT**: the registration/offering model that future flow will plug into.
**FUTURE**: an actual commerce-event webhook confirming a registration.
The boundary itself is unchanged: the website owns marketing/pricing DISPLAY and the hosted
checkout experience, RootNode's platform owns the actual price (server-controlled, never
browser-supplied), payment verification, and everything after a verified commerce event. Phase 7
documented the flow; **Phase 9 built it for real**: an offering can carry a price
(`offerings.price_amount`/`price_currency`), a payment-provider abstraction (Stripe or a
deterministic in-process test provider — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`) creates a
hosted checkout session server-side, and a verified, idempotent webhook is the ONLY thing that
ever confirms payment and drives `registration confirmed → enrollment activated` — a browser
`?paid=true` redirect proves nothing and is never trusted. See `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's contract and `docs/
STRIPE_INTEGRATION_RUNBOOK.md` for going live with real Stripe.
## First commercial target: Winter Break Camp
@@ -141,6 +145,13 @@ planned this phase.
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
| Public registration flow (a real website-side form), payments, subscriptions | FUTURE |
| Offering pricing, payment-provider abstraction, hosted checkout, verified webhook | **PHASE 9 BUILT** (`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) — Stripe test-mode + a deterministic in-process test provider; real Stripe live-mode validation is pending real credentials. |
| Agreement acceptance on the public registration contract | **PHASE 9 BUILT** — closes the gap Phase 8 left open. |
| Refund/cancellation recording (commerce status only, never auto-cascading) | PHASE 9 BUILT |
| Abandoned-reservation reaper, reservation expiry for paid offerings | PHASE 9 BUILT |
| Admin payment UI (price, payment/refund status, no card data) | PHASE 9 BUILT |
| A real website-side registration/checkout form (rootnodeacademy.com itself) | FUTURE — contract ready, form not built in this repo (separate repo, not RootNode-owned). |
| Live Stripe validation (real credentials, real card, real webhook from Stripe's servers) | FUTURE — application layer is done and Stripe-test-mode-tested; pending real Stripe credentials. |
| Subscriptions, membership billing, coupons, gift cards, installment plans, tax automation | FUTURE, explicitly out of scope |
| INIT_/COMPILE_ curriculum | FUTURE |
| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope |
| School/library SIS integrations, parent portal, guardian login | FUTURE, explicitly out of scope |
+134
View File
@@ -0,0 +1,134 @@
# Stripe Integration Runbook
Operational reference for RootNode's payment-provider abstraction (`platform/api/src/
payment-provider.js`) — configuration, local testing against both the deterministic test
provider and real Stripe test mode, webhook setup, and what's left before a real, live-money
launch. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md` for the design/architecture story and
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own contract.
## The two providers
| | `PAYMENT_PROVIDER=test` (default) | `PAYMENT_PROVIDER=stripe` |
|---|---|---|
| Network calls | None — fully in-process | Real calls to Stripe's API |
| Needs credentials | No | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
| Checkout URL | A fake URL pointing back at your own `success_url` | A real Stripe-hosted Checkout page |
| Webhook signature | A static shared secret (`TEST_PAYMENT_WEBHOOK_SECRET`, header `X-Test-Payment-Signature`) | Real Stripe HMAC signature (header `Stripe-Signature`), verified via Stripe's own SDK |
| Real money | Never | Real in live mode; fake (test cards) in Stripe's own test mode |
| Used by | Every automated test/rehearsal in this repo | Nothing yet — pending real credentials |
`commerce.js` and every route above it call the SAME methods regardless of which provider is
active (`createCheckoutSession`, `verifyAndParseWebhook`) — switching providers is an environment
variable, never a code change.
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `PAYMENT_PROVIDER` | `test` | `test` or `stripe`. |
| `STRIPE_SECRET_KEY` | unset | Required for `stripe`. Server-side only — never exposed to any browser-served code. |
| `STRIPE_WEBHOOK_SECRET` | unset | Required for `stripe`. The signing secret Stripe gives you for a specific webhook endpoint (dashboard or `stripe listen`, see below) — NOT your API secret key. |
| `TEST_PAYMENT_WEBHOOK_SECRET` | `test-payment-secret-not-for-production` | Only used by the `test` provider. Change if you need to isolate parallel test runs; otherwise the default is fine. |
| `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` | `30` | How long a paid offering's seat stays reserved before the reaper releases it if checkout is never completed. |
| `RESERVATION_REAPER_POLL_MS` | `60000` | How often the reservation reaper checks for expired, unpaid reservations. |
None of these belong in `frontend/` or anywhere a browser can read them — the Stripe secret key
and webhook secret are server-only, exactly like `INTEGRATION_SHARED_SECRET`/
`PROVISIONER_SHARED_SECRET` already are in this codebase.
## Local testing against the test provider (no Stripe account needed)
This is what every e2e suite and rehearsal in this repo actually runs against. Leave
`PAYMENT_PROVIDER` unset (defaults to `test`) and drive the full cycle directly:
```bash
# 1. Create a checkout session (server-to-server, same as the website would)
curl -s -X POST http://localhost:4200/api/integrations/registrations/<id>/checkout \
-H 'Content-Type: application/json' \
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
-d '{"success_url":"https://example.com/s","cancel_url":"https://example.com/c"}'
# 2. Look up the provider_reference the checkout just created
docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform \
-c "SELECT provider_reference FROM payment_transactions WHERE registration_id = '<id>' ORDER BY created_at DESC LIMIT 1;"
# 3. Build and POST a fake "payment succeeded" webhook event (Node, using the same provider
# module the real server uses — see platform/test/e2e-phase9.js for the full pattern)
node -e "
const { getPaymentProvider } = require('./platform/api/src/payment-provider');
const p = getPaymentProvider();
const event = p.buildTestEvent({ providerReference: '<provider_reference from step 2>', eventType: 'checkout.session.completed' });
console.log(JSON.stringify(event));
"
curl -s -X POST http://localhost:4200/api/webhooks/payment \
-H 'Content-Type: application/json' \
-H "X-Test-Payment-Signature: <event.signature from above>" \
-d '<event.body from above>'
```
To simulate a **failed payment** or **abandoned checkout**, use `eventType:
'payment_intent.payment_failed'` or `'checkout.session.expired'` in step 3 instead — see
`platform/test/e2e-phase9.js` for both, and for the reservation-reaper path (never send a webhook
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
test).
## Local testing against real Stripe TEST mode (once credentials exist)
Not yet exercised in this repo — no Stripe account/credentials have been provided as of this
phase. Once they exist:
1. Create (or use an existing) Stripe account, switch to **Test mode** in the dashboard.
2. Copy the test-mode **Secret key** (`sk_test_...`) into `STRIPE_SECRET_KEY`.
3. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) and run:
```bash
stripe login
stripe listen --forward-to localhost:4200/api/webhooks/payment
```
The CLI prints a webhook signing secret (`whsec_...`) — set that as `STRIPE_WEBHOOK_SECRET`
for the duration of the local session (it's ephemeral per `stripe listen` run; a real
deployment instead creates a permanent webhook endpoint in the Stripe dashboard and uses ITS
signing secret).
4. Set `PAYMENT_PROVIDER=stripe` and restart the API.
5. Drive the checkout flow through steps 2-4 of `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` — you
now get back a REAL `checkout_url`. Open it in a browser and pay with a Stripe test card
(`4242 4242 4242 4242`, any future expiry, any CVC, any ZIP).
6. `stripe listen` forwards Stripe's real webhook event to your local server; watch the server
log and `payment_webhook_events` table for `processed`.
7. To simulate a failure without a real failing card, use Stripe's documented test card numbers
for declines (e.g. `4000 0000 0000 0002`), or `stripe trigger payment_intent.payment_failed`.
This is the recommended path to real validation before going live — it exercises the actual
Stripe SDK, actual signature verification, and actual Stripe event delivery, with zero real
money at risk.
## Going live (real money)
1. Switch the Stripe dashboard to **Live mode**, generate a live **Secret key**
(`sk_live_...`), and create a permanent webhook endpoint pointed at the production API's
`/api/webhooks/payment` URL — copy ITS signing secret (`whsec_...`, different from test mode's).
2. Set `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) and `PAYMENT_PROVIDER=stripe` in
production configuration only — never commit these to git, never put them in a `.env` file
that gets version-controlled (matching this repo's existing secret-handling convention for
`PROVISIONER_SHARED_SECRET`/`INTEGRATION_SHARED_SECRET`).
3. Set a real price on the live offering (`PATCH /api/admin/offerings/:id`) and confirm
`GET /api/admin/offerings/:id/readiness` shows `payment_provider: ready`.
4. Run one real, small, genuinely-refundable transaction against a real card before opening
registration publicly, and confirm the full pipeline (checkout → webhook → confirmation →
enrollment → activation email) end to end, then refund it via the admin UI or the Stripe
dashboard directly.
5. Monitor `rootnode_payment_webhooks_total`/`rootnode_payments_total` (Prometheus metrics, see
`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) after opening registration.
**This phase does not perform steps 1-4 above** — no real Stripe credentials were provided, and
inventing/obtaining them is a business decision reserved for Chase, not something this phase
invents. Everything up to this runbook is built and tested against the test provider; live
validation is the one explicitly pending item.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `503 PROVIDER_NOT_CONFIGURED` on checkout | `PAYMENT_PROVIDER=stripe` but `STRIPE_SECRET_KEY` unset, or the `stripe` npm package failed to load. |
| `400 invalid_signature` on every real Stripe webhook | `STRIPE_WEBHOOK_SECRET` doesn't match the endpoint Stripe actually delivered to (test-mode secret used against a live endpoint, or vice versa — each `stripe listen`/dashboard endpoint has its OWN secret). |
| Webhook received but nothing changes | Check `payment_webhook_events.result` for that event id — `duplicate` means it was already processed (expected on a retry); check server logs for an exception if `result` is missing entirely. |
| Seat never releases after an abandoned checkout | Confirm the reservation reaper is running (`RESERVATION_REAPER_POLL_MS` log line on server startup) and that `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` hasn't been set unreasonably high. |
+57 -22
View File
@@ -212,14 +212,21 @@ appear; `draft` and `cancelled` never do. Example response:
"registration_open": true,
"availability": "limited",
"location_name": "Community Room B",
"location_type": "community_center"
"location_type": "community_center",
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
scraping/timing games against a public endpoint). No internal notes, no organization contact
info, no instructor identity, no pricing — the website owns pricing display entirely; this
endpoint doesn't return it.
info, no instructor identity — just enough to build a pricing/availability display. **As of Phase
9**: `price_amount` is an integer in minor currency units (cents — `24900` = $249.00), `null` for
a free offering; `price_currency` is a lowercase ISO code (`"usd"`); `requires_payment` is a
plain derived boolean (`price_amount !== null`) so the website never has to reimplement that
check. The actual checkout amount is always read server-side from this same offering row at
checkout-creation time — the website never sends an amount anywhere in this contract.
### 2. Select an offering, then submit a registration (server-to-server)
@@ -285,35 +292,63 @@ required; `phone` optional). RootNode matches an existing guardian by email firs
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
guardian's email is never written to the eventual student's own identity record.
### 6. Agreement flow
### 6. Agreement flow (Phase 9 — live)
Not yet exposed on this endpoint — an agreement acceptance is currently recorded by RootNode
staff (or a future self-service step) after the registration is submitted, before it can be
confirmed. If your registration flow collects agreement acceptance at submission time, talk to
the RootNode team about extending this contract before building around a workaround.
```
POST /api/integrations/registrations/:id/agreements
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
### 7. Future payment step
Same shared-secret header as every other route in this section. Which party accepts is
server-derived from the registration's own `is_minor`, never a value you send: an adult accepts
as themselves (before their student account even exists yet), a minor's registration is accepted
by the guardian already on file from submission. `201 { "accepted": true, "agreement_id": ...,
"accepted_version": ... }` on success; `404 AGREEMENT_NOT_FOUND` if the id doesn't match a real
agreement configured on this offering. **Checkout is blocked (`409 AGREEMENTS_INCOMPLETE`) until
every required agreement is accepted** — call this before calling checkout below.
Not built. Today, a registration is manually moved to `confirmed` by RootNode staff once payment
is verified through whatever process exists outside the platform. A future phase's real payment
integration would do this automatically on a verified commerce event.
### 7. Checkout (Phase 9 — live)
### 8. Confirmation
```
POST /api/integrations/registrations/:id/checkout
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Once `confirmed` (manually today, automatically in a future payment-integrated phase) and every
required agreement is accepted, the registration can convert to a real enrollment.
Server-to-server only — the browser never calls this directly, and never supplies a price. The
amount, currency, and product description all come from the offering's own server-side
configuration. Success: `201 { "checkout_url": "...", "transaction_id": "..." }` — redirect the
customer's browser to `checkout_url` (RootNode's configured payment provider's own hosted
checkout page; today that's Stripe Checkout in test mode, or a fake URL from the deterministic
test provider in local dev — see `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`). A free offering
(`price_amount: null`) returns `409 NO_PRICE_CONFIGURED` — never call this for a free offering;
just proceed straight to confirmation. Full error table, the success-page design (never trust a
returning `?paid=true`), and the status-lookup endpoint are in `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md`.
### 9. Enrollment activation
### 8. Payment confirmation — a verified webhook, never a browser redirect
Conversion creates (or matches) the real student account and a real academic enrollment, reusing
the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no
second activation path exists.
RootNode's own webhook (not something the website calls) is the ONLY thing that confirms
payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real
enrollment automatically — reusing the exact activation-email flow described below, no separate
"payment succeeded" email exists. See `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for how to
build a safe success page against this (poll the status-lookup endpoint; never derive success
from the URL alone).
### 9. Confirmation and enrollment activation
Once `confirmed` (via the webhook above for a priced offering, or a manual admin transition for
a free one) and every required agreement is accepted, the registration converts to a real
enrollment — creating or matching the real student account and reusing the exact same
activation-email machinery `/api/integrations/enrollments` already triggers. No second
confirmation path and no second activation path exist.
### 10. What this endpoint does NOT do
No payment-card data (never sent, never has a field for it). No password (RootNode issues its
own credential/activation flow). No arbitrary registration status (server-defined transitions
only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example data above is fake — do not
No payment-card data (never sent, never has a field for it — see `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password
(RootNode issues its own credential/activation flow). No arbitrary registration status
(server-defined transitions only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example
data above is fake — do not
reuse these values against a real deployment.
## What the student experiences after you call this (as of Phase 5C)
+210
View File
@@ -0,0 +1,210 @@
# Website → RootNode Registration & Checkout
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
(Winter Break Camp and anything after it). This is the Phase 9 companion to
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
the success page, and status lookup.
## The end-to-end flow
```
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
2. POST /api/integrations/registrations — submit the registration (server-to-server)
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
5. redirect the customer's browser to the returned checkout_url
6. RootNode's webhook confirms payment — NOT something the website calls
7. GET /api/integrations/registrations/:id/status — poll from the success page
```
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
browser directly.
## 1. Price and availability display
```
GET /api/public/offerings/:slug
```
No auth needed. As of Phase 9, the response includes:
```json
{
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
**This price is display-only.** The actual amount charged always comes from the SAME offering
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
contract for the website to submit an amount. A browser (or a compromised website backend)
cannot pay a different price than what's configured on the offering.
## 2. Submit the registration
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
## 3. Accept the agreement(s)
```
POST /api/integrations/registrations/:id/agreements
Header: X-RootNode-Integration-Key: <secret>
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
Which party is recorded as accepting is **server-derived from the registration's own
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
```json
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
```
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
from step 4). Call this before checkout, always.
## 4. Create the checkout session
```
POST /api/integrations/registrations/:id/checkout
Header: X-RootNode-Integration-Key: <secret>
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
is no `amount` field — see "Price and availability display" above.
Success (`201`):
```json
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
```
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
`window.location`). In local/staging use with the deterministic test provider (see
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
to visit, which is fine for automated testing but means you can't manually click through a
"real" checkout page against the test provider.
**Errors:**
| Status | `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong integration key. |
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
## 5. Payment confirmation — never trust the redirect
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
enrolled" or grant anything based on the URL/query string alone.**
## 6. Building a safe success page
```
GET /api/integrations/registrations/:id/status
Header: X-RootNode-Integration-Key: <secret>
```
Call this from your OWN backend (never expose the integration key to the browser) when the
customer lands on your success page, and poll it every couple of seconds for a short window (the
webhook is normally processed within a second or two of the customer completing checkout — see
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
```json
{
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
"status": "confirmed",
"payment_status": "paid",
"enrollment_activated": false
}
```
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
of three states on your success page:
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
your email for your RootNode Academy activation link."
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
payment — this page will update automatically" (keep polling, or tell the customer to check
back / check email shortly).
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
priced offering, a failed payment does NOT release the seat, so the SAME registration can
retry checkout (back to step 4) without losing its place.
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
"expired" webhook processes it — at that point the seat has been released and the customer needs
to start a new registration if they still want in.
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
## 7. What you must NOT do
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
Stripe's hosted Checkout page collects it directly.
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
add one to your own request body it is silently ignored (the price always comes from the
offering).
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
fires automatically on webhook-confirmed conversion (same machinery `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
duplicate or conflict with it.
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
customer to check their email and stop polling.
## Accessibility and loading-state notes
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
return).
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
the customer looking at a page that appears frozen while you poll.
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
map each to a real, specific message rather than one generic "something went wrong."
## Test-mode flow (what to actually test against)
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
for a true end-to-end rehearsal before anything goes live with real money.
## Privacy note
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
already documents for registration (names, emails, phone) — nothing payment-specific is added.
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
provider reference — never card data).
+50 -35
View File
@@ -1,24 +1,31 @@
# Winter Break Camp Operations
Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases
7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers
everything up to and including the point where a real payment/checkout would plug in.
Phase 8 §29 (payment step updated in Phase 9) — how to actually run the Winter Break Linux &
Systems Foundations Camp (`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the
platform built in Phases 7-9, end to end, entirely from `/admin`. As of Phase 9, real
checkout/payment (Stripe test mode today, live mode once real credentials exist) is part of this
flow — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md` and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`.
## What already exists vs. what Chase still fills in
**Already representable, real, tested:**
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
- The offering itself, in DRAFT, with delivery mode `camp`.
- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort →
activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing).
- The offering itself, in DRAFT, with delivery mode `camp`, and a real price.
- Registration → guardian/adult → agreement → real checkout → verified webhook → confirmation →
enrollment → cohort → activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` (13/13 passing, test payment
provider, no live money) — plus failed-payment retry, abandoned-checkout seat release, and a
last-seat checkout race, none of which existed in Phase 8's own rehearsal.
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
- Exact dates, venue, real capacity, and price.
- Exact dates, venue, real capacity, and price (the price FIELD is real and editable; the actual
dollar amount to charge is still Chase's call).
- The actual agreement text (a plain-language placeholder only — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- Whether registration opens before or after a specific marketing push.
- Real Stripe credentials — until they exist, the platform runs on the deterministic test
provider, functionally complete but not yet validated against Stripe's own live systems. See
`docs/STRIPE_INTEGRATION_RUNBOOK.md`.
## The operational sequence
@@ -35,40 +42,48 @@ everything up to and including the point where a real payment/checkout would plu
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
page's own instructor step, or the two admin routes directly.
5. **Configure the required agreement(s)** — create the agreement definition (title + plain-
5. **Set the price** — `PATCH /api/admin/offerings/:id` with `price_amount`/`price_currency`
(or leave `price_amount` unset for a free offering). Confirm `PAYMENT_PROVIDER` is configured
(`test` for rehearsal, `stripe` with real credentials for a live launch — see `docs/
STRIPE_INTEGRATION_RUNBOOK.md`).
6. **Configure the required agreement(s)** — create the agreement definition (title + plain-
language summary) and link it to the offering. **The actual legal text still needs
professional review before this camp is opened to real families** — see below.
6. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing. If instructor/curriculum/agreements are configured but dates/
location/capacity aren't final yet, readiness will honestly show that.
7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
7. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing, including the new `price` and `payment_provider` checks. If
instructor/curriculum/agreements/price are configured but dates/location/capacity aren't
final yet, readiness will honestly show that.
8. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
`enrollment_open` does the offering appear in the public offerings API and accept real
registrations.
8. **Registrations arrive** — via the website integration (once wired — see
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an
admin for a phone/in-person sign-up.
9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement
acceptance action.
10. **Payment step (future, not built)** — this is the one step this phase does not implement.
Today, an admin manually transitions a registration to `confirmed` once payment is verified
by whatever process exists outside the platform; a future phase's real payment integration
would do this automatically on a verified commerce event, the same shape
`/api/integrations/enrollments` already uses for direct program enrollment.
11. **Convert to enrollment** — creates the real student account (or matches an existing one),
the real academic enrollment, and real cohort membership.
12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
9. **Registrations arrive** — via the website integration (contract ready, see
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`) or entered directly by an admin for a phone/
in-person sign-up.
10. **Guardian/adult completes the agreement, then checkout** — the agreement acceptance and
checkout-creation endpoints are both live (Phase 9); checkout is blocked until the agreement
is accepted.
11. **Payment confirmation** — a verified, idempotent webhook (never a browser redirect) is what
actually confirms payment and walks the registration to `confirmed`. A failed payment leaves
the registration retriable without releasing the seat; an abandoned checkout or an expired,
unpaid reservation releases it.
12. **Convert to enrollment** — happens automatically on payment confirmation for a priced
offering (or a manual admin transition for a free one) — creates the real student account
(or matches an existing one), the real academic enrollment, and real cohort membership.
13. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
activation token, delivered the same way every other RootNode activation email already is.
13. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
14. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
and work through real ROOT 01-02 content and Labs.
14. **Offering completion** — `in_progress → completed` once the camp concludes.
15. **Offering completion** — `in_progress → completed` once the camp concludes.
## Known blockers before this can actually open to the public
- Real dates, venue, capacity, and price are not yet decided (this document does not invent
them).
- Real dates, venue, capacity, and the actual dollar price are not yet decided (this document
does not invent them — the platform fields to hold them are real and ready).
- The agreement text requires professional legal review (see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`).
- No payment processing exists — step 10 above is manual until a future phase builds it.
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet.
- Real Stripe credentials do not exist yet — the commerce pipeline is built and tested against
the deterministic test provider only; see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for going live.
- The website's own registration/checkout UI does not exist yet in this repository
(rootnodeacademy.com is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`), but nobody has built the website-side form yet.
- See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full go/no-go list.
+75
View File
@@ -0,0 +1,75 @@
# Winter Break Camp — Launch Checklist
The single go/no-go list for opening the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) to real public registration. Combines
`docs/WINTER_BREAK_CAMP_OPERATIONS.md`'s operational sequence with Phase 9's commerce
requirements into one checklist Chase can walk top to bottom. Nothing on this list is invented by
the platform — every unchecked item is a real decision or asset that has to come from Chase, the
legal reviewer, or Stripe, not from more code.
## Platform readiness — done, verified, ready whenever the business inputs below land
- [x] Offering, capacity, registration lifecycle, agreements — built and tested (Phase 7/8).
- [x] Offering pricing field, payment-provider abstraction, hosted checkout, verified webhook,
refund recording, abandoned-reservation reaper — built and tested against the deterministic
test provider (Phase 9).
- [x] Website registration + checkout + agreement + status-lookup contract — documented and
tested (`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`).
- [x] Admin UI for offering pricing and per-registration payment/refund status.
- [x] `GET /api/admin/offerings/:id/readiness` reflects real configuration state, including the
new `price` and `payment_provider` checks.
- [x] Full Winter Break commerce rehearsal green end to end against a real, freshly-built stack
(`infra/scripts/drills/test-phase9-winter-camp-commerce.js`, 13/13): admin configuration,
minor and adult registration, checkout, payment success, webhook, enrollment activation, a
real classroom session, a real Lab task, failed payment, duplicate webhook, abandoned
checkout, and a last-seat race.
## Business decisions — Chase's call, not invented by this phase
- [ ] Real camp dates and duration.
- [ ] Real venue/location (or confirm fully online/remote).
- [ ] Real capacity (seat count).
- [ ] Real price and currency.
- [ ] Whether registration opens ahead of, or alongside, a specific marketing push.
- [ ] Refund policy in plain terms (the platform can RECORD any refund decision; it does not
decide policy — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s "Payment and the commerce
boundary" section for what "record, never auto-cascade" means operationally).
## Legal review — required before any real family sees the agreement
- [ ] The actual participation/media/liability agreement text, reviewed by a real attorney
(`agreement_definitions.body_summary` is currently a plain-language placeholder only — see
`docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- [ ] Jurisdiction-specific minor-consent requirements confirmed.
- [ ] Data-retention/deletion policy decided and, if needed, reflected in the agreement text.
- [ ] A decision on whether emergency-contact fields need additional encryption at rest.
## Payments — pending real Stripe credentials
- [ ] Real Stripe account created/available (live mode).
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
configuration only — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section.
- [ ] At least one real, small, refundable test transaction run end to end in production before
opening registration publicly, then refunded.
- [ ] Recommended (not required — the test provider already validates the application logic):
one full rehearsal against Stripe's own TEST mode (fake cards, no real money) before
flipping to live credentials, per `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s test-mode section.
## Website (separate repo, not RootNode-owned)
- [ ] rootnodeacademy.com's own registration/checkout form built against
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`. Not tracked or built in this repository — if
that repo becomes available to RootNode's own engineering effort, say so explicitly before
any code changes are made against it.
- [ ] The website's `INTEGRATION_SHARED_SECRET` provisioned into its OWN backend configuration
(never browser-reachable) — see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s "Production
secret handling."
- [ ] The website's success page built against the status-lookup polling pattern in
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` §6 — never derived from the redirect URL alone.
## Final go/no-go
Registration should not open to the public until every box above is checked. The platform side
(first section) has been true since this phase shipped; everything else is a business, legal, or
external-team dependency this phase deliberately did not — and should not — invent on Chase's
behalf.
+62
View File
@@ -188,6 +188,12 @@ function OfferingDetail({ offeringId, canAuthor, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Pricing</h3>
<p>{offering.price_amount === null ? <span className={styles.muted}>Free — no checkout required.</span> : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}</>}</p>
{canAuthor && <SetPriceForm offeringId={offeringId} current={offering} onSaved={reload} />}
</div>
<div className={styles.card}>
<h3>Launch readiness</h3>
{!readiness ? <p className={styles.muted}>Loading…</p> : (
@@ -291,6 +297,36 @@ function CreateRegistrationForm({ offeringId, onCreated }) {
)
}
function SetPriceForm({ offeringId, current, onSaved }) {
const [amount, setAmount] = useState(current.price_amount === null ? '' : (current.price_amount / 100).toFixed(2))
const [currency, setCurrency] = useState(current.price_currency || 'usd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.updateOffering(offeringId, {
priceAmount: amount === '' ? null : Math.round(Number(amount) * 100),
priceCurrency: currency,
})
onSaved()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Price (blank = free, no checkout)</label>
<input className={styles.input} type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} /></div>
<div><label className={styles.label}>Currency</label>
<input className={styles.input} value={currency} onChange={(e) => setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} /></div>
<button className={styles.btnSecondary} disabled={busy}>Save price</button>
</form>
)
}
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
const [agreementId, setAgreementId] = useState('')
const [busy, setBusy] = useState(false)
@@ -321,6 +357,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
const [reg, setReg] = useState(null)
const [agreementStatus, setAgreementStatus] = useState(null)
const [notifs, setNotifs] = useState(null)
const [payments, setPayments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
@@ -335,6 +372,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {})
}, [registrationId])
useEffect(reload, [reload])
@@ -387,6 +425,30 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Payments</h3>
{!payments ? <p className={styles.muted}>Loading…</p> : payments.length === 0 ? <p className={styles.muted}>No checkout attempted — this offering may be free, or checkout hasn't started yet.</p> : (
<table>
<thead><tr><th>Amount</th><th>Status</th><th>Provider</th><th>Paid</th><th /></tr></thead>
<tbody>
{payments.map((p) => (
<tr key={p.transaction_id}>
<td>{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}</td>
<td><span className={`${styles.pill} ${p.status === 'paid' ? styles.pillOk : p.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{p.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{p.provider}</td>
<td className={styles.muted}>{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'}</td>
<td>
{canAuthor && p.status === 'paid' && (
<button className={styles.linkBtn} onClick={withBusy(() => adminApi.recordRefund(p.transaction_id, 'refunded'))}>Record refund</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Communications</h3>
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
+3
View File
@@ -144,6 +144,9 @@ export const adminApi = {
offering: (id) => get(`/api/admin/offerings/${id}`),
createOffering: (fields) => post('/api/admin/offerings', fields),
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
updateOffering: (id, fields) => patch(`/api/admin/offerings/${id}`, fields),
registrationPayments: (id) => get(`/api/admin/registrations/${id}/payments`),
recordRefund: (transactionId, status) => post(`/api/admin/payments/${transactionId}/refund`, { status }),
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Orchestrates the Phase 9 Step 30 Winter Break commercial rehearsal: brings up the real dev
# stack, bootstraps an owner, runs test-phase9-winter-camp-commerce.js (real checkout/webhook
# processing against the deterministic test payment provider — no live Stripe, no real money),
# tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
export PAYMENT_PROVIDER="${PAYMENT_PROVIDER:-test}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P9 Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase9-winter-camp-commerce.js
@@ -0,0 +1,249 @@
#!/usr/bin/env node
// Phase 9 Step 30 — the full Winter Break commercial rehearsal, WITH real checkout/webhook
// processing this time (Phase 8's rehearsal simulated payment confirmation via a plain admin
// transition; this one drives the actual commerce path end to end). Uses the deterministic
// 'test' payment provider — no live money, no real Stripe credentials anywhere in this script.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const { getPaymentProvider } = require('../../../platform/api/src/payment-provider');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function webhookPost(body, signature) {
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 9 Winter Break commercial rehearsal — real checkout/webhook, test provider, no live money\n');
const provider = getPaymentProvider();
assert.equal(provider.name, 'test');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const locId = `p9-loc-${RUN_ID}`, cohortId = `p9-cohort-${RUN_ID}`, offeringId = `p9-camp-${RUN_ID}`, slug = `p9-linux-systems-${RUN_ID}`, agreementId = `p9-agreement-${RUN_ID}`;
await step('admin configures the test offering — price, capacity, location, agreement, instructor', async () => {
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
const create = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
capacity: 2, locationId: locId, cohortId, priceAmount: 20000, priceCurrency: 'usd',
});
assert.equal(create.status, 201);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p9-instructor-${RUN_ID}@example.com`, display_name: 'P9 Instructor', role: 'instructor', password: 'P9InstructorPass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
// Dates/registration-window are deliberately NOT set — real business decisions this phase
// must not invent (Steps 4/28/32) — so overall readiness is honestly false. Every check
// this rehearsal DID configure must show ready; the ones nobody has decided yet must not.
const byKey = Object.fromEntries(readiness.body.checks.map((c) => [c.key, c.ready]));
for (const key of ['curriculum', 'instructor', 'location', 'capacity', 'agreements', 'price', 'payment_provider']) {
assert.equal(byKey[key], true, `expected ${key} to be ready: ${JSON.stringify(readiness.body.checks)}`);
}
assert.equal(readiness.body.ready_to_open_registration, false, 'must honestly stay not-fully-ready until real dates are set');
});
await step('admin opens registration', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
// ── Minor registration -> agreement -> checkout -> real payment -> enrollment -> Lab ──────
let minorRegId, minorTransactionRef;
await step('a minor registers via the website, guardian accepts the agreement, checkout is created', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p9-minor-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Camper', is_minor: true },
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
});
assert.equal(submit.status, 201);
minorRegId = submit.body.registration_id;
const preCheckout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
assert.equal(preCheckout.status, 409, 'checkout must be blocked before the agreement is accepted');
const accept = await integrationPost(`/api/integrations/registrations/${minorRegId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
const tx = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
minorTransactionRef = tx.body[0].provider_reference;
});
let minorStudent, minorSession, minorEnrollmentId, minorPaymentEvent;
await step('the test payment provider confirms payment via a REAL webhook call — registration converts, account activates', async () => {
minorPaymentEvent = provider.buildTestEvent({ providerReference: minorTransactionRef, eventType: 'checkout.session.completed' });
const webhookResult = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
assert.equal(webhookResult.status, 200);
assert.equal(webhookResult.body.result, 'processed');
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
assert.equal(reg.body.status, 'enrollment_activated');
const cohortRoster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.ok(cohortRoster.body.some((s) => s.student_id === reg.body.student_id));
minorEnrollmentId = reg.body.enrollment_id;
// The real activation token was already delivered to the (dev) email provider by the
// notification worker — retrieved here via the same dev-preview mechanism e2e-phase5c.js's
// own suite already established, never a raw-token-in-API-response shortcut (that only
// exists on the admin-authored convert route, not the webhook path — see notifications.js's
// own comment on why toAdminView() never includes it).
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 3000));
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation');
assert.ok(activationNotif, `expected a real activation notification, got: ${JSON.stringify(notifs.body)}`);
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
if (!preview.body || !preview.body.text) throw new Error(`dev-preview failed: status=${preview.status} notif=${JSON.stringify(activationNotif)} body=${JSON.stringify(preview.body)}`);
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
minorStudent = { student_id: reg.body.student_id };
minorSession = makeSession(GW);
const activate = await minorSession.request('POST', '/api/student/activate', { token, password: 'WinterP9Minor123!' });
assert.equal(activate.status, 200, JSON.stringify(activate.body));
});
await step('the duplicate webhook (a real Stripe retry scenario) changes nothing further', async () => {
// The EXACT same event (same event id) as the prior step — a real Stripe retry redelivers
// the identical event, it never mints a new event id for "the same" delivery. Building a
// fresh test event here would defeat the point: it's calling buildTestEvent() twice that
// simulates two DIFFERENT events, not a retry of the same one.
const replay = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
assert.equal(replay.body.result, 'duplicate');
});
// ── Adult registration, real payment, independent conversion ──────────────────────────────
await step('an adult registers, pays, and converts independently — no guardian involved', async () => {
const submit = await integrationPost('/api/integrations/registrations', { event_id: `p9-adult-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false } });
assert.equal(submit.status, 201);
await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/agreements`, { agreement_id: agreementId });
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.completed' });
await webhookPost(event.body, event.signature);
const reg = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
assert.equal(reg.body.status, 'enrollment_activated');
});
// ── Capacity now exhausted (2/2) — a third registration must fail cleanly ────────────────
await step('a failed payment leaves a retriable registration without consuming a phantom extra seat', async () => {
const failSlugOffering = `p9-fail-camp-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: `off-${failSlugOffering}`, slug: failSlugOffering, name: 'Fail Camp', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlugOffering, registrant: { display_name: 'Fail Registrant', email: `failreg-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
await webhookPost(event.body, event.signature);
const offAfter = await owner.request('GET', `/api/admin/offerings/off-${failSlugOffering}`);
assert.equal(offAfter.body.reserved_seats, 1, 'the seat stays reserved so the same registrant can retry checkout');
});
// ── Abandoned checkout releases the seat back to the pool ────────────────────────────────
await step('an abandoned checkout (expired session) releases the seat', async () => {
const abandonOffering = `off-p9-abandon-${RUN_ID}`, abandonSlug = `p9-abandon-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: abandonOffering, slug: abandonSlug, name: 'Abandon Test', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonreg-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Abandoner', email: `abandoner-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.expired' });
await webhookPost(event.body, event.signature);
const offAfter = await owner.request('GET', `/api/admin/offerings/${abandonOffering}`);
assert.equal(offAfter.body.reserved_seats, 0, 'an abandoned checkout must release its seat');
// Prove the released seat is genuinely usable again — a fresh registration succeeds.
const retry = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonretry-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Retry Registrant', email: `retry-${RUN_ID}@example.com` } });
assert.equal(retry.status, 201);
});
// ── Last-seat race: two students competing, exactly one wins ────────────────────────────
await step('two students genuinely compete for the final seat of a paid offering — exactly one wins', async () => {
const raceOffering = `off-p9-lastseat-${RUN_ID}`, raceSlug = `p9-lastseat-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: raceOffering, slug: raceSlug, name: 'Last Seat', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'enrollment_open' });
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last A', email: `lasta-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last B', email: `lastb-${RUN_ID}@example.com` } }),
]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
});
// ── Real classroom + Lab work for the actually-enrolled minor student ────────────────────
await step('instructor starts a classroom session and Prepares Labs', async () => {
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
assert.equal(session.status, 201);
global.__classroomId = session.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
assert.equal(prep.status, 200);
});
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
assert.equal(join.status, 200);
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
}
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase9-winter-hardware-${envRow.environment_id}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true);
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
});
await step('instructor sees real roster, payment, and progress state for the camp', async () => {
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(roster.body.length, 2);
const payments = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
assert.equal(payments.body[0].status, 'paid');
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
assert.ok(mod.activities.some((a) => a.complete));
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+15 -3
View File
@@ -118,12 +118,24 @@ further instructor accounts from the Instructors tab).
(sequential and concurrent idempotency), required-agreement conversion blocking with version
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
paced/classroom flows remain unaffected.
- `test/e2e-phase9.js` — offering pricing in the public serializer, checkout blocked until
agreements are accepted, the server-authoritative amount (never caller-supplied), webhook
signature verification and idempotency, payment success driving real conversion/activation, a
failed payment leaving the seat intact vs. an abandoned checkout releasing it, the reservation
reaper, a last-seat checkout race, manual refund with no cascade into registration/enrollment
status, minor payment confirmations routing to the guardian, the admin payment view (never card
data), instructor/public RBAC on payment data, audit/metric hygiene (no secrets, no
high-cardinality labels), the website status-lookup endpoint's minimal safe shape, and
confirmation that ROOT_ V1/free-offering/classroom flows remain unaffected.
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
rehearsals) — see each script's own header and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/
PHASE8_ENROLLMENT_OPERATIONS.md` for what they prove beyond what the e2e suites
above already cover via direct API.
rehearsals, Phase 9's Winter Break COMMERCE rehearsal —
`test-phase9-winter-camp-commerce.js`, 13/13, real checkout/webhook against the deterministic
test payment provider, no live money) — see each script's own header and
`docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/PHASE8_ENROLLMENT_OPERATIONS.md`/`docs/
PHASE9_COMMERCE_AND_LAUNCH.md` for what they prove beyond what the e2e suites above already
cover via direct API.
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
@@ -0,0 +1,71 @@
-- Phase 9 — the narrow commerce/payment-record model around Phase 8's registration system.
-- Stripe (or any future provider) is a COMMERCE provider only — this schema is what RootNode
-- itself understands, never a copy of Stripe's own object model. No card numbers, no CVV, no
-- raw payment-method details anywhere in this schema; Stripe remains the PCI boundary. See
-- docs/PHASE9_COMMERCE_AND_LAUNCH.md.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offerings — price is nullable: a NULL price means this offering never requires checkout (the
-- existing Phase 7/8 free-registration path is completely unchanged for it). amount is in minor
-- currency units (cents), matching Stripe's own convention, so no float-vs-integer rounding
-- question ever arises.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_amount INTEGER; -- cents; NULL = free/no-checkout
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_currency TEXT NOT NULL DEFAULT 'usd';
ALTER TABLE offerings DROP CONSTRAINT IF EXISTS offerings_price_amount_nonnegative;
ALTER TABLE offerings ADD CONSTRAINT offerings_price_amount_nonnegative CHECK (price_amount IS NULL OR price_amount >= 0);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_registrations — reservation_expires_at is set only for a registration against a
-- PAID offering (commerce.js's own responsibility, not a DB default) — the abandoned-
-- reservation reaper (Step 27) releases the seat and cancels the registration once this passes
-- with no successful payment, exactly the "reserve at registration, expire if unpaid" model
-- Step 26 asks to prove. NULL forever for a free offering — nothing new to expire there.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE offering_registrations ADD COLUMN IF NOT EXISTS reservation_expires_at TIMESTAMPTZ;
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- payment_transactions — one row per checkout attempt (a registration can have more than one,
-- e.g. a failed attempt followed by a retry). provider_reference is the provider's own opaque
-- id (a Stripe Checkout Session id) — never a card/payment-method identifier. amount/currency
-- are SNAPSHOTTED from the offering at checkout-creation time (matching every other
-- "session snapshots its own terms" precedent in this schema — assessment sessions, agreement
-- acceptances) so a later price change never reinterprets an already-created transaction.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS payment_transactions (
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
registration_id UUID NOT NULL REFERENCES offering_registrations(registration_id),
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
provider_reference TEXT, -- e.g. Stripe Checkout Session id
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'cancelled', 'refunded', 'partially_refunded')),
amount INTEGER NOT NULL, -- cents, snapshotted from the offering at creation
currency TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
authorized_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
refunded_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_payment_transactions_registration ON payment_transactions(registration_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_transactions_provider_reference ON payment_transactions(provider_reference) WHERE provider_reference IS NOT NULL;
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- payment_webhook_events — the ENTIRE webhook idempotency mechanism: a real DB-level unique
-- primary key on the provider's own event id (Stripe already guarantees these are globally
-- unique), not a SELECT-then-process check. A duplicate delivery of the same event (Stripe
-- explicitly documents retries as expected/normal) always tries the same INSERT, always loses
-- the race gracefully via ON CONFLICT DO NOTHING, and is therefore always a safe no-op no
-- matter how many times or how concurrently it arrives.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS payment_webhook_events (
webhook_event_id TEXT PRIMARY KEY, -- the provider's own event id
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
event_type TEXT NOT NULL,
transaction_id UUID REFERENCES payment_transactions(transaction_id),
result TEXT NOT NULL DEFAULT 'received' CHECK (result IN ('received', 'processed', 'ignored', 'error')),
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
+18 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.19.2",
"pg": "^8.11.5"
"pg": "^8.11.5",
"stripe": "^22.6.0"
}
},
"node_modules/accepts": {
@@ -1178,6 +1179,22 @@
"node": ">=0.10.0"
}
},
"node_modules/stripe": {
"version": "22.6.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.0.tgz",
"integrity": "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw==",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+2 -1
View File
@@ -10,8 +10,9 @@
"bootstrap-owner": "node bootstrap-owner.js"
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.19.2",
"pg": "^8.11.5",
"better-sqlite3": "^9.4.3"
"stripe": "^22.6.0"
}
}
+26 -1
View File
@@ -9,6 +9,7 @@
const offerings = require('./offerings');
const notifications = require('./notifications');
const commerce = require('./commerce');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) {
@@ -17,7 +18,8 @@ function errStatus(code) {
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|| code === 'AGREEMENTS_INCOMPLETE') return 409;
|| code === 'AGREEMENTS_INCOMPLETE' || code === 'NO_PRICE_CONFIGURED') return 409;
if (code === 'PROVIDER_NOT_CONFIGURED') return 503;
return 500;
}
@@ -31,6 +33,7 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
};
function handleError(res, e) {
if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message });
@@ -116,6 +119,15 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
if (!offering) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(offering);
});
// Phase 9 §28 — the one route Chase actually uses to fill in dates/venue/capacity/price once
// those real business decisions are made, on an offering that already exists as a draft.
admin.patch('/api/admin/offerings/:id', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.updateOffering(getPool(), req.params.id, req.body || {});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_update', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { fields: Object.keys(req.body || {}) } });
res.json(offering);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status);
@@ -171,6 +183,19 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
const rows = await notifications.listForRegistration(getPool(), req.params.id);
res.json(rows.map(notifications.toAdminView));
});
// Phase 9 §18/§19 — payment visibility. The row itself never contains card data (only
// amount/currency/status/timestamps/the provider's own opaque reference), so it's safe to
// return whole — admin/owner only, same as every other registration-detail surface.
admin.get('/api/admin/registrations/:id/payments', requireAdminOrOwner, async (req, res) => {
res.json(await commerce.listTransactionsForRegistration(getPool(), req.params.id));
});
admin.post('/api/admin/payments/:id/refund', requireAdminOrOwner, async (req, res) => {
try {
const transaction = await commerce.recordManualRefund(getPool(), req.params.id, req.body?.status || 'refunded');
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'refund_recorded', resourceType: 'payment_transaction', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { status: transaction.status } });
res.json(transaction);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);
+223
View File
@@ -0,0 +1,223 @@
// Phase 9 — checkout/payment business logic. Sits ABOVE offerings.js (registration/agreement/
// conversion) and payment-provider.js (the Stripe/test abstraction) — this file is the only
// place either one gets combined with the other. Same error convention as offerings.js/
// enrollment.js: throw `Error` with a `.code` the route layer maps to an HTTP status.
const offerings = require('./offerings');
const notificationsLib = require('./notifications');
const metrics = require('./metrics');
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function err(code, message) {
return Object.assign(new Error(message || code), { code });
}
// ── Checkout creation (Step 5/6) ─────────────────────────────────────────────────────────────
// Verifies, in order: registration exists and hasn't already gone past the point checkout makes
// sense; the offering actually has a price configured (a free offering never needs this at
// all); registration is still open (mirrors offerings.js's own assertRegistrationOpen, since an
// offering can close between registration and checkout); every required agreement is already
// accepted (Step 6's explicit "before checkout"). amount/currency ALWAYS come from the
// offering's own server-side price — never from the caller, closing Step 5's explicit
// "browser cannot choose price" requirement structurally, not just by convention.
async function createCheckoutSession(pool, provider, registrationId, { successUrl, cancelUrl }) {
const reg = await offerings.getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
if (['enrollment_activated', 'cancelled'].includes(reg.status)) throw err('INVALID_STATE', `A registration in status '${reg.status}' cannot start checkout.`);
const offering = await offerings.getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
if (offering.price_amount === null) throw err('NO_PRICE_CONFIGURED', 'This offering has no price configured — it does not require checkout.');
offerings.assertRegistrationOpen(offering);
const agreementStatus = await offerings.getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const { rows } = await pool.query(
`INSERT INTO payment_transactions (registration_id, provider, status, amount, currency)
VALUES ($1, $2, 'pending', $3, $4) RETURNING *`,
[registrationId, provider.name, offering.price_amount, offering.price_currency]
);
const transaction = rows[0];
let session;
try {
session = await provider.createCheckoutSession({
amountCents: offering.price_amount, currency: offering.price_currency, productName: offering.name,
successUrl, cancelUrl, metadata: { registration_id: registrationId, transaction_id: transaction.transaction_id },
});
} catch (e) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
metrics.recordCheckoutSession('error');
throw e;
}
await pool.query(`UPDATE payment_transactions SET provider_reference = $2, updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id, session.providerReference]);
metrics.recordCheckoutSession('created');
return { transactionId: transaction.transaction_id, checkoutUrl: session.checkoutUrl, providerReference: session.providerReference };
}
// ── Webhook processing (Step 8/9/10/11) — idempotent by construction ────────────────────────
// The INSERT into payment_webhook_events is the entire idempotency mechanism (a real unique
// primary key on the provider's own event id, ON CONFLICT DO NOTHING) — if this call reports
// `duplicate`, NOTHING below it ever runs, so a payment can never be double-recorded, an
// enrollment can never be double-converted, and a notification can never be double-sent no
// matter how many times (or how concurrently) the same event arrives.
async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent) {
const { eventId, eventType, providerReference, normalizedStatus } = normalizedEvent;
const { rows: inserted } = await pool.query(
`INSERT INTO payment_webhook_events (webhook_event_id, provider, event_type) VALUES ($1, $2, $3)
ON CONFLICT (webhook_event_id) DO NOTHING RETURNING *`,
[eventId, provider.name, eventType]
);
if (!inserted[0]) {
metrics.recordWebhook(eventType, 'duplicate');
return { result: 'duplicate' };
}
if (!normalizedStatus) {
await pool.query(`UPDATE payment_webhook_events SET result = 'ignored', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'ignored');
return { result: 'ignored' };
}
const { rows: txRows } = await pool.query(`SELECT * FROM payment_transactions WHERE provider_reference = $1`, [providerReference]);
const transaction = txRows[0];
if (!transaction) {
await pool.query(`UPDATE payment_webhook_events SET result = 'error', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'error');
return { result: 'error', reason: 'UNKNOWN_TRANSACTION' };
}
await pool.query(`UPDATE payment_webhook_events SET transaction_id = $2, result = 'processed', processed_at = now() WHERE webhook_event_id = $1`, [eventId, transaction.transaction_id]);
if (normalizedStatus === 'paid') await handlePaymentSuccess(pool, studentsAuth, transaction);
else if (normalizedStatus === 'failed') await handlePaymentFailure(pool, transaction);
else if (normalizedStatus === 'cancelled') await handleCheckoutAbandoned(pool, transaction);
else if (normalizedStatus === 'refunded' || normalizedStatus === 'partially_refunded') await handleRefund(pool, transaction, normalizedStatus);
metrics.recordWebhook(eventType, 'processed');
metrics.recordPayment(normalizedStatus, provider.name);
return { result: 'processed', normalizedStatus };
}
// paid -> transaction paid -> registration confirmed -> the EXACT existing Phase 8 conversion
// flow (never duplicated here) -> notifications. Reuses offerings.transitionRegistration/
// convertRegistrationToEnrollment exactly as an admin action already would.
async function handlePaymentSuccess(pool, studentsAuth, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'paid', paid_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (!reg || ['enrollment_activated', 'cancelled'].includes(reg.status)) { metrics.recordConversion('skipped'); return; }
// Walk the registration up to 'confirmed' through whatever real transitions are actually
// still needed — a website submission may already be 'submitted'/'agreements_pending', an
// admin-entered one might still be 'started'. Only ever moves FORWARD through the same
// server-defined transition table offerings.js already enforces, never skips a real state.
const path = { started: 'submitted', submitted: 'agreements_pending', agreements_pending: 'confirmed', reviewed: 'confirmed', payment_pending: 'confirmed' };
let current = reg.status;
while (current !== 'confirmed' && path[current]) {
await offerings.transitionRegistration(pool, transaction.registration_id, path[current]);
current = path[current];
}
if (current !== 'confirmed') { metrics.recordConversion('blocked'); return; } // e.g. AGREEMENTS_INCOMPLETE would have thrown above already, but stay defensive
try {
// No separate "registration confirmed" notification here — transitionRegistration('confirmed')
// above already fired that one (Phase 8's own logic). The "you're enrolled, here's how to
// log in" signal comes from convertRegistrationToEnrollment's own notifyForEnrollment call
// (Phase 9's fix — see that function's comment) — sending both would be a real, needless
// duplicate email to the same family for the same underlying event.
await offerings.convertRegistrationToEnrollment(pool, studentsAuth, transaction.registration_id, {});
metrics.recordConversion('success');
} catch (e) {
metrics.recordConversion('error');
console.error('[commerce] enrollment conversion after payment failed:', e.message);
}
}
async function handlePaymentFailure(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg) {
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_needs_action',
metadata: { display_name: reg.registrant_display_name, offering_name: (await offerings.getOffering(pool, reg.offering_id))?.name, reason: 'Your payment did not go through — please try again.' },
}).catch((e) => console.error('[commerce] payment-failure notification failed:', e.message));
}
// Deliberately does NOT release the seat or cancel the registration — a failed payment
// attempt is not the same as an abandoned one; the registrant may simply retry checkout with
// the same reservation still held. See handleCheckoutAbandoned/the reaper for actual release.
}
// A checkout that expired without ever completing (Stripe's own checkout.session.expired, or
// the reaper's timeout below) — release the seat, cancel the registration. Never touches a
// PAID registration (both callers only ever act on a still-pending transaction/registration).
async function handleCheckoutAbandoned(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'cancelled', cancelled_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg && !['enrollment_activated', 'cancelled'].includes(reg.status)) {
await offerings.transitionRegistration(pool, transaction.registration_id, 'cancelled'); // releases the seat internally
metrics.recordSeatReleased('checkout_expired');
}
}
// Refund recording only — Step 11's explicit "do not automatically erase academic history."
// Commerce status (this table) is fully separate from registration status (offering_
// registrations.status) and academic enrollment status (enrollments.status) — a refund NEVER
// cascades into cancelling either of those automatically; that remains a real, separate
// business decision an admin makes deliberately (see docs/PHASE9_COMMERCE_AND_LAUNCH.md's
// "three separate statuses" section).
async function handleRefund(pool, transaction, status) {
const column = status === 'refunded' ? 'refunded_at' : null;
await pool.query(
`UPDATE payment_transactions SET status = $2, ${column ? `refunded_at = now(),` : ''} updated_at = now() WHERE transaction_id = $1`,
[transaction.transaction_id, status]
);
}
// ── Admin-recorded refund. No automated Stripe refund call this phase — Step 11 explicitly
// scopes this to RECORDING a refund that happened, not building a refund-management product. ──
async function recordManualRefund(pool, transactionId, status) {
if (!['refunded', 'partially_refunded'].includes(status)) throw err('VALIDATION', "status must be 'refunded' or 'partially_refunded'.");
const { rows } = await pool.query(
`UPDATE payment_transactions SET status = $2, refunded_at = now(), updated_at = now() WHERE transaction_id = $1 AND status = 'paid' RETURNING *`,
[transactionId, status]
);
if (!rows[0]) throw err('INVALID_STATE', 'Only a paid transaction can be marked refunded.');
return rows[0];
}
// ── Abandoned reservation reaper (Step 27) ───────────────────────────────────────────────────
// Finds registrations whose reservation window has passed with no successful payment, and
// releases them exactly the way an expired checkout would — same function, same audit
// footprint. Never touches a registration already enrollment_activated or cancelled (the
// WHERE clause excludes both), so a paid registration can never be swept up here regardless of
// timing.
async function reapExpiredReservations(pool) {
const { rows } = await pool.query(
`SELECT * FROM offering_registrations
WHERE reservation_expires_at IS NOT NULL AND reservation_expires_at < now()
AND status NOT IN ('enrollment_activated', 'cancelled', 'confirmed')`
);
let released = 0;
for (const reg of rows) {
await offerings.transitionRegistration(pool, reg.registration_id, 'cancelled');
released++;
metrics.recordSeatReleased('reservation_expired');
}
return { checked: rows.length, released };
}
async function listTransactionsForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM payment_transactions WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
return rows;
}
module.exports = {
RESERVATION_TIMEOUT_MINUTES, createCheckoutSession, processWebhookEvent, recordManualRefund,
reapExpiredReservations, listTransactionsForRegistration,
};
+71
View File
@@ -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',
});
}));
};
+30
View File
@@ -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,
};
+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) {
+94 -13
View File
@@ -121,15 +121,15 @@ const OFFERING_TRANSITIONS = {
cancelled: [],
};
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy }) {
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy, priceAmount, priceCurrency }) {
if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.');
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
try {
const { rows } = await pool.query(
`INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`,
[offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null]
`INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by, price_amount, price_currency)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,COALESCE($20,'usd')) RETURNING *`,
[offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null, priceAmount ?? null, priceCurrency || null]
);
return rows[0];
} catch (e) {
@@ -141,6 +141,35 @@ async function getOffering(pool, offeringId) {
const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]);
return rows[0] || null;
}
// Phase 9 §28 — a draft offering is created with the bare minimum, then completed over time as
// real business decisions (dates, venue, capacity, price) land — this is the one place those
// get filled in later without recreating the row. Only ever touches columns explicitly present
// in `fields`, so calling it with just `{ priceAmount, priceCurrency }` never disturbs anything
// else. Never allows changing `status` (transitionOffering is the only path for that, with its
// own server-defined transition table) or `slug`/`program_id` (identity fields).
const OFFERING_EDITABLE_FIELDS = {
name: 'name', description: 'description', startsAt: 'starts_at', endsAt: 'ends_at',
enrollmentOpensAt: 'enrollment_opens_at', enrollmentClosesAt: 'enrollment_closes_at', capacity: 'capacity',
locationId: 'location_id', organizationId: 'organization_id', cohortId: 'cohort_id',
recommendedMinAge: 'recommended_min_age', recommendedMaxAge: 'recommended_max_age',
curriculumPackageId: 'curriculum_package_id', priceAmount: 'price_amount', priceCurrency: 'price_currency',
};
async function updateOffering(pool, offeringId, fields) {
const sets = [];
const values = [offeringId];
for (const [key, column] of Object.entries(OFFERING_EDITABLE_FIELDS)) {
if (!(key in fields)) continue;
values.push(fields[key]);
sets.push(`${column} = $${values.length}`);
}
if (!sets.length) throw err('VALIDATION', 'No editable fields provided.');
const { rows } = await pool.query(
`UPDATE offerings SET ${sets.join(', ')}, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`,
values
);
if (!rows[0]) throw err('NOT_FOUND', 'No such offering.');
return rows[0];
}
async function listOfferings(pool, { status } = {}) {
const { rows } = await pool.query(
status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`,
@@ -223,6 +252,15 @@ function assertRegistrationOpen(offering) {
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references.
// Phase 9 §26/§27 — a registration against a PAID offering carries a reservation_expires_at,
// giving the registrant a real but bounded window to complete checkout before the seat releases
// automatically (server.js's reservation reaper). A free offering never sets this — nothing new
// to expire, identical to Phase 7/8 behavior.
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function computeReservationExpiry(offering) {
return offering.price_amount === null ? null : new Date(Date.now() + RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
}
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
@@ -235,9 +273,9 @@ async function createRegistration(pool, { offeringId, guardianId, registrantDisp
let reg;
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11,$12) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null, computeReservationExpiry(offering)]
);
reg = rows[0];
} catch (e) {
@@ -346,7 +384,10 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
studentEmail: reg.is_minor ? null : reg.registrant_email,
studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null,
studentDisplayName: reg.registrant_display_name,
}, { returnToken: true }); // admin-only surface — see enrollment.js's own comment on why this is safe here but not on the public integration endpoint
}); // no returnToken here — notifyForEnrollment below issues the ONE real token that
// actually gets used; pre-issuing a second one here would just have it silently
// overwritten (found while wiring Phase 9's activation-notification call — see that
// call's own comment).
const enrollment = await enrollmentLib.createManualEnrollment(pool, {
studentId: student.student_id, programId: offering.program_id,
@@ -362,11 +403,41 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
);
}
// Phase 9 — the registration's own guardian (set at registration time when a guardian object
// was submitted) never used to survive conversion into the relational student_guardians model,
// which is what resolveRecipient's guardian fallback (and any future guardian-facing feature)
// actually reads. Without this, a brand-new minor student converts with no guardian link at
// all, and the activation email below has no recipient to resolve to.
if (reg.is_minor && reg.guardian_id) {
await linkStudentGuardian(pool, {
studentId: student.student_id, guardianId: reg.guardian_id,
relationship: 'guardian', isPrimaryContact: true, canReceiveCommunications: true,
});
}
await pool.query(
`UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`,
[registrationId, student.student_id, enrollment.enrollment_id]
);
return { student, enrollment };
// Phase 9 §9/§20 — the same activation-or-confirmation email every OTHER enrollment path
// (the admin manual-enroll route, the website's /api/integrations/enrollments) already
// triggers automatically. A registration-converted enrollment was the one path that never
// called this — closed here so a new camp student's real path to logging in doesn't depend
// on an admin manually relaying `student._activationToken` by hand.
const programsLib = require('./programs');
const program = await programsLib.getProgram(pool, offering.program_id);
let activationToken = null;
try {
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
studentId: student.student_id, enrollmentId: enrollment.enrollment_id, programName: program?.name || offering.name,
});
activationToken = notification?._activationToken || null;
} catch (e) {
console.error('[offerings] enrollment-activation notification failed:', e.message);
}
return { student: activationToken ? { ...student, _activationToken: activationToken } : student, enrollment };
}
// ── Agreements ────────────────────────────────────────────────────────────────────────────────
@@ -454,11 +525,11 @@ async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringS
}
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8)
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId]
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId, computeReservationExpiry(offering)]
);
if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, {
@@ -501,6 +572,16 @@ async function computeOfferingReadiness(pool, offeringId) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
}
// Phase 9 §29 — a PAID offering (price_amount set) additionally needs a real price and a
// configured payment provider before it's genuinely launch-ready; a free offering never
// needs either, unchanged from Phase 8.
if (offering.price_amount !== null) {
checks.push({ key: 'price', label: 'Price configured', ready: offering.price_amount > 0 && !!offering.price_currency });
const { getPaymentProvider } = require('./payment-provider');
let providerConfigured = false;
try { const p = getPaymentProvider(); providerConfigured = p.name === 'test' || !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET); } catch { providerConfigured = false; }
checks.push({ key: 'payment_provider', label: 'Payment provider configured', ready: providerConfigured });
}
const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
}
@@ -510,7 +591,7 @@ module.exports = {
createLocation, listLocations,
createGuardian, linkStudentGuardian, listGuardiansForStudent,
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
+114
View File
@@ -0,0 +1,114 @@
// Phase 9 §3/§4 — the payment provider abstraction. commerce.js (registration/checkout
// business logic) only ever calls the methods below — it never touches the Stripe SDK, an env
// var, or a webhook signature format directly. Swapping providers later means writing a new
// class here, not touching commerce.js/admin-commerce-routes.js/webhook-routes.js at all.
//
// PAYMENT_PROVIDER selects the implementation: 'stripe' (real, needs STRIPE_SECRET_KEY/
// STRIPE_WEBHOOK_SECRET) or 'test' (deterministic, in-process, no network, no real money —
// the default, matching EMAIL_PROVIDER's own "dev by default, real provider opt-in" precedent).
// Every test/rehearsal in this phase runs against 'test'; nothing here has ever been exercised
// against live Stripe credentials — see docs/STRIPE_INTEGRATION_RUNBOOK.md's own "pending
// live validation" note.
const PAYMENT_PROVIDER = process.env.PAYMENT_PROVIDER || 'test';
// ── Stripe adapter ────────────────────────────────────────────────────────────────────────────
class StripeProvider {
constructor() {
this.name = 'stripe';
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is not set — checkout creation and webhook verification will fail every call until it is.');
}
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || null;
if (!this.webhookSecret) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_WEBHOOK_SECRET is not set — every webhook delivery will be rejected until it is.');
}
// Lazy require — the stripe package is a real dependency (package.json), but constructing
// the client eagerly at module load would crash a 'test'-provider dev stack that never
// installs/needs a real key at all.
const Stripe = require('stripe');
this.client = secretKey ? new Stripe(secretKey) : null;
}
// amountCents/currency come from the OFFERING's own server-side price, never browser input
// (Step 5's explicit requirement) — commerce.js is the only caller, and it reads them from
// the offerings table itself.
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
if (!this.client) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing).'), { code: 'PROVIDER_NOT_CONFIGURED' });
const session = await this.client.checkout.sessions.create({
mode: 'payment',
line_items: [{ price_data: { currency, unit_amount: amountCents, product_data: { name: productName } }, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata,
});
return { checkoutUrl: session.url, providerReference: session.id };
}
// Real signature verification via Stripe's own SDK helper — never a hand-rolled HMAC compare.
// Throws on a bad/forged signature; the webhook route treats that as a clean 400, never a 500.
verifyAndParseWebhook(rawBody, signatureHeader) {
if (!this.client || !this.webhookSecret) throw Object.assign(new Error('Stripe webhook verification is not configured.'), { code: 'PROVIDER_NOT_CONFIGURED' });
const event = this.client.webhooks.constructEvent(rawBody, signatureHeader, this.webhookSecret);
return this.normalizeEvent(event);
}
// Stripe's own event vocabulary -> RootNode's narrow, provider-neutral shape. Only the event
// types this integration actually acts on are mapped to a real status; everything else comes
// back with normalizedStatus: null, which webhook-routes.js records (for audit completeness)
// but never acts on.
normalizeEvent(event) {
const obj = event.data.object;
let providerReference = obj.id;
let normalizedStatus = null;
if (event.type === 'checkout.session.completed') { normalizedStatus = 'paid'; providerReference = obj.id; }
else if (event.type === 'checkout.session.expired') { normalizedStatus = 'cancelled'; providerReference = obj.id; }
else if (event.type === 'payment_intent.payment_failed') { normalizedStatus = 'failed'; providerReference = obj.metadata?.checkout_session_id || obj.id; }
else if (event.type === 'charge.refunded') { normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded'; providerReference = obj.payment_intent || obj.id; }
return { eventId: event.id, eventType: event.type, providerReference, normalizedStatus };
}
}
// ── Test/dev adapter — deterministic, in-process, no network, no real money ─────────────────
// Every "checkout" is an immediately-resolvable fake URL; a rehearsal/test drives payment
// outcomes by POSTing a fabricated event straight at the SAME webhook route real Stripe events
// arrive on, through the SAME signature-verification interface (a static shared secret compare
// here, standing in for Stripe's HMAC check) — the actual commerce.js processing code path is
// therefore identical in both providers, only how a session/event gets created differs.
class TestProvider {
constructor() {
this.name = 'test';
this.sharedSecret = process.env.TEST_PAYMENT_WEBHOOK_SECRET || 'test-payment-secret-not-for-production';
this._sessions = new Map(); // in-process only — fine, this provider only ever exists in dev/test
}
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
const providerReference = `test_cs_${Math.random().toString(36).slice(2)}${Date.now()}`;
this._sessions.set(providerReference, { amountCents, currency, metadata });
return { checkoutUrl: `${successUrl}${successUrl.includes('?') ? '&' : '?'}test_session=${providerReference}`, providerReference };
}
// A rehearsal/test calls this directly (not a real HTTP round trip) to build the exact body a
// real client would POST to the webhook route.
buildTestEvent({ providerReference, eventType }) {
const body = JSON.stringify({ id: `evt_${Math.random().toString(36).slice(2)}${Date.now()}`, type: eventType, provider_reference: providerReference });
return { body, signature: this.sharedSecret };
}
verifyAndParseWebhook(rawBody, signatureHeader) {
if (signatureHeader !== this.sharedSecret) throw Object.assign(new Error('Invalid test webhook signature.'), { code: 'INVALID_SIGNATURE' });
const event = JSON.parse(rawBody);
const statusByType = { 'checkout.session.completed': 'paid', 'checkout.session.expired': 'cancelled', 'payment_intent.payment_failed': 'failed', 'charge.refunded': 'refunded' };
return { eventId: event.id, eventType: event.type, providerReference: event.provider_reference, normalizedStatus: statusByType[event.type] || null };
}
}
let cachedProvider = null;
function getPaymentProvider() {
if (cachedProvider) return cachedProvider;
cachedProvider = PAYMENT_PROVIDER === 'stripe' ? new StripeProvider() : new TestProvider();
return cachedProvider;
}
module.exports = { getPaymentProvider, StripeProvider, TestProvider, PAYMENT_PROVIDER };
+10 -4
View File
@@ -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]
+19
View File
@@ -35,6 +35,16 @@ const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
const app = express();
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
// Phase 9 §8 — the ONE route that needs the raw, unparsed request body: a payment provider's
// webhook signature (Stripe's own HMAC, or the test provider's stand-in) is computed over the
// exact bytes as sent, so express.json() must never touch this path before signature
// verification runs. Registered before the global JSON parser below so Express's normal
// path-then-registration-order middleware resolution gives this route the raw Buffer
// (`req.rawBody`) instead — every other route is completely unaffected.
app.use('/api/webhooks/payment', express.raw({ type: '*/*' }), (req, res, next) => {
req.rawBody = req.body; // a Buffer here, never parsed — webhook-routes.js reads it directly
next();
});
app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST));
@@ -426,6 +436,15 @@ require('./admin-offerings-routes')(app, {
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
require('./public-offerings-routes')(app, { getPool, wrap });
// Phase 9 — the payment provider webhook (Stripe or the test provider), and the reservation
// reaper for abandoned/unpaid registrations. See docs/PHASE9_COMMERCE_AND_LAUNCH.md.
require('./webhook-routes')(app, { getPool, recordAudit, wrap });
const commerceLib = require('./commerce');
const RESERVATION_REAPER_POLL_MS = Number(process.env.RESERVATION_REAPER_POLL_MS) || 60000;
setInterval(() => {
commerceLib.reapExpiredReservations(getPool()).catch((e) => console.error('[commerce] reservation reaper failed:', e.message));
}, RESERVATION_REAPER_POLL_MS);
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+43
View File
@@ -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 });
}));
};
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env node
// Phase 9 — Commerce Integration acceptance test. Price configuration, the public price
// serializer, checkout eligibility (agreements/registration-window/price), server-authoritative
// amount, verified webhook processing (signature, idempotency, success/failure/refund),
// registration confirmation -> enrollment conversion -> activation, guardian recipient routing,
// capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility,
// RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows
// are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials,
// no real money, deterministic.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase9.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const { getPaymentProvider } = require('../api/src/payment-provider');
const commerce = require('../api/src/commerce');
const { Pool } = require('../api/node_modules/pg');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function integrationGet(path) {
const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function webhookPost(body, signature) {
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) {
await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' });
if (requireAgreement) {
const agreementId = `agree-${offeringId}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {});
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
}
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
}
async function main() {
console.log('Phase 9 Commerce Integration acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
const provider = getPaymentProvider();
assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe');
// ── 1/2: price configuration, public price serializer ────────────────────────────────────
const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`;
await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => {
await setupPaidOffering(owner, { offeringId, slug, capacity: 3 });
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
assert.equal(pub.price_amount, 15000);
assert.equal(pub.price_currency, 'usd');
assert.equal(pub.requires_payment, true);
for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`);
});
// ── 3/4: checkout eligibility, missing agreements rejected ───────────────────────────────
let registrationId;
await test('checkout is rejected until required agreements are accepted', async () => {
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
registrationId = reg.body.registration_id;
const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
});
// ── 5: closed registration rejected ───────────────────────────────────────────────────────
await test('registration is rejected on a draft/closed offering', async () => {
const draftOffering = `off-p9-draft-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } });
assert.equal(reg.status, 409);
assert.equal(reg.body.error, 'OFFERING_NOT_OPEN');
});
// ── 6: full offering rejected ─────────────────────────────────────────────────────────────
await test('a full offering rejects further registrations', async () => {
const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false });
const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } });
assert.equal(r1.status, 201);
const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } });
assert.equal(r2.status, 409);
assert.equal(r2.body.error, 'OFFERING_FULL');
});
// ── 7/8: server-authoritative amount, checkout creation ──────────────────────────────────
let transactionId, providerReference;
await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => {
const agreementId = `agree-${offeringId}`;
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 });
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
transactionId = checkout.body.transaction_id;
const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`);
assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent');
const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
providerReference = txRow.body[0].provider_reference;
});
// ── 9/10/11: webhook signature, invalid rejection, idempotency ───────────────────────────
await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => {
const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' });
const bad = await webhookPost(event.body, 'wrong-signature');
assert.equal(bad.status, 400);
assert.equal(bad.body.error, 'invalid_signature');
const first = await webhookPost(event.body, event.signature);
assert.equal(first.status, 200);
assert.equal(first.body.result, 'processed');
const replay = await webhookPost(event.body, event.signature);
assert.equal(replay.status, 200);
assert.equal(replay.body.result, 'duplicate');
const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`);
assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment');
});
// ── 12/14/15/16: payment success, registration confirmation, conversion, activation ──────
let convertedStudentId;
await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => {
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(reg.body.status, 'enrollment_activated');
convertedStudentId = reg.body.student_id;
assert.ok(convertedStudentId);
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active');
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
const types = notifs.body.map((n) => n.notification_type);
assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`);
// no duplicate registration_confirmed
assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1);
});
// ── 13: payment failure ────────────────────────────────────────────────────────────────────
await test('a failed payment does not release the seat and leaves the registration retriable', async () => {
const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
const result = await webhookPost(event.body, event.signature);
assert.equal(result.body.result, 'processed');
const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`);
assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat');
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
});
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false });
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [201, 409]);
const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`);
assert.equal(off.body.reserved_seats, 1);
});
// ── 20/21: abandoned reservation release ─────────────────────────────────────────────────
await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => {
const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } });
psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`);
const result = await commerce.reapExpiredReservations(pool);
assert.ok(result.released >= 1);
const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`);
assert.equal(offAfter.body.reserved_seats, 0);
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'cancelled');
});
// ── 22: refund recording ──────────────────────────────────────────────────────────────────
await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
const paid = tx.body.find((t) => t.status === 'paid');
const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' });
assert.equal(refund.status, 200);
assert.equal(refund.body.status, 'refunded');
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment');
});
// ── 17: guardian recipient routing ────────────────────────────────────────────────────────
await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => {
const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false });
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug,
registrant: { display_name: 'Minor Camper', is_minor: true },
guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` },
});
assert.equal(submit.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
const txRow = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' });
await webhookPost(event.body, event.signature);
const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`);
assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email');
const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`);
assert.ok(notifs.body.some((n) => n.notification_type === 'activation'));
});
// ── 18: admin payment view ────────────────────────────────────────────────────────────────
await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.ok(tx.body.length > 0);
for (const t of tx.body) {
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
}
});
// ── 24/25: instructor payment-data restrictions, student payment isolation ──────────────
await test('a plain instructor cannot see registration payment data; the public API never can either', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' });
const stray = makeSession(GW);
await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' });
const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.equal(denied.status, 403);
});
// ── 26/27: audit hygiene, metric hygiene ─────────────────────────────────────────────────
await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => {
const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`);
assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata');
const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null);
// Metrics may be unauthenticated-admin or absent in some deployments — only assert content
// shape when reachable, never fail the whole suite on an unrelated auth requirement.
if (metricsBody && metricsBody.ok) {
const text = await metricsBody.text();
assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label');
}
});
await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => {
const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`);
assert.equal(status.status, 200);
assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']);
const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`);
assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route');
});
// ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ───────
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
});
await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => {
const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(checkoutAttempt.status, 409);
assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED');
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p9-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' });
assert.equal(session.status, 201);
});
await pool.end();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });