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).
This commit is contained in:
2026-08-27 09:06:04 -05:00
parent 281d60bc2d
commit 80a5f6e457
8 changed files with 884 additions and 74 deletions
+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