New: PHASE9_1_STRIPE_TEST_VALIDATION.md — baseline verification, the exact Stripe-credential search performed (none found), the Stripe architecture review (dynamic price_data, unchanged), what was proven vs. blocked per the mission's own step list, and the live-mode readiness checklist. Updated: STRIPE_INTEGRATION_RUNBOOK.md and WINTER_BREAK_LAUNCH_CHECKLIST.md to point at the new opt-in test and the blocked status. PHASE9_COMMERCE_AND_LAUNCH.md gets a short addendum. No application code changed this phase — no genuine defect was exposed without real Stripe traffic, so no regression rerun was required.
283 lines
19 KiB
Markdown
283 lines
19 KiB
Markdown
# 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
|