Files
rootnode-academy/docs/PHASE9_COMMERCE_AND_LAUNCH.md
T
ced4568 a4c0058f8c docs(stripe): document real Stripe TEST-mode validation results
PHASE9_1_STRIPE_TEST_VALIDATION.md rewritten from BLOCKED to the real, proven results: a real
Stripe TEST Checkout, a real TEST payment at the founder-approved $399 Founding Cohort price, a
genuine signed webhook, the existing registration->enrollment conversion, a real
duplicate-delivery proof, a real automated abandoned-checkout proof, a real refund with the
non-cascade rule reconfirmed, full Stripe<->RootNode reconciliation IDs, and the one real defect
found and fixed. STRIPE_INTEGRATION_RUNBOOK.md updated (real-Stripe local testing is no longer
'not yet exercised'; notes the no-sudo Stripe CLI install path and --api-key/--print-secret
usage). WINTER_BREAK_LAUNCH_CHECKLIST.md: Stripe TEST validation checked, price checked ($399
launch / $449 retail per Chase directly), live-mode items correctly still unchecked.
PHASE9_COMMERCE_AND_LAUNCH.md's Phase 9.1 addendum updated to reflect completion.
2026-08-28 09:47:11 -05:00

20 KiB

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.

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.

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 completed — a real Stripe TEST Checkout, a real TEST payment (at the founder-approved $399 Founding Cohort price), a genuine signed webhook, and the existing registration→enrollment conversion were all proven end to end, plus real duplicate-delivery, abandoned-checkout, and refund proofs. One real defect (webhook event correlation for PaymentIntent/Charge-referencing events, invisible to the deterministic TestProvider) was found and fixed with a permanent regression test added. See docs/ PHASE9_1_STRIPE_TEST_VALIDATION.md for the full evidence. PAYMENT_PROVIDER=stripe, STRIPE_SECRET_KEY, and STRIPE_WEBHOOK_SECRET are now wired through platform/docker-compose.dev.yml (empty/unset by default — the deterministic TestProvider remains the default for routine dev/test).


PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING