# 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.1 addendum**: real Stripe TEST-mode validation was attempted immediately after this phase and remained blocked on the exact same missing credentials — see `docs/ PHASE9_1_STRIPE_TEST_VALIDATION.md`. That phase added a permanent, opt-in integration test (`platform/test/stripe-integration-test.js`, default no-op, refuses live-mode keys) ready to run the real proof the moment `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (test mode) exist. --- ## PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING