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.
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) andpayment_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 narrownormalizeEvent()mapping Stripe's event vocabulary to{eventId, eventType, providerReference, normalizedStatus}) andTestProvider(deterministic, in-process, no network, no real money — the default, matchingEMAIL_PROVIDER's existing "dev by default, real provider opt-in" precedent).commerce.jsand 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 combiningofferings.jsandpayment-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 inserver.js), checksStripe-Signature/X-Test-Payment-Signature, treats a bad signature as a clean, quiet400(never a 500, never a hint about why), always responds200once verification passes (a duplicate/ignored event is recorded, not retried forever).integration-routes.jsextended —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 ownis_minor, never caller-supplied),POST /api/integrations/registrations/:id/checkout(server-to-server only, the amount always comes from the offering's own price), andGET /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.jsextended —reservation_expires_atcomputed only for priced offerings (computeReservationExpiry());OFFERING_EDITABLE_FIELDS/updateOffering()(dynamic, allowlistedPATCH, never touches status/slug/program_id);computeOfferingReadiness()gainedpriceandpayment_providerchecks;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/adminApiconventions exactly. - Reservation reaper — a
setIntervalpoller (server.js) releasing any paid-offering reservation whosereservation_expires_athas passed and which never reached a terminal state (enrollment_activated/cancelled/confirmedexcluded). - 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)
- Duplicate
registration_confirmednotification.handlePaymentSuccessoriginally fired an explicitregistration_confirmednotification AFTERtransitionRegistration('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. - A converted registration never sent an activation email.
convertRegistrationToEnrollmentcreated the real student/enrollment/cohort rows but never callednotifyForEnrollment— 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): - A converted minor student had no real guardian link.
convertRegistrationToEnrollmentcreates the new student and enrollment but never promoted the registration's ownguardian_idinto the relationalstudent_guardianstable — sonotifications.js's guardian-recipient fallback (resolveRecipient()) had nothing to resolve to, and the new activation email failed withno_recipient_address. Fixed by calling the existinglinkStudentGuardian()helper (already used elsewhere for admin-managed guardian links, never previously wired into conversion) wheneverreg.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 forREGISTRATION_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.jsand the commerce rehearsal, as two genuinely different code paths (handlePaymentFailurevs.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 ine2e-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
amountfield (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. Seedocs/STRIPE_INTEGRATION_RUNBOOK.md. - No waitlist (deliberate, see "Design decisions" above).
registration_needs_actionstill 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 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).