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
+61 -7
View File
@@ -1,9 +1,10 @@
# Offering & Registration Model # Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings, Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8, extended again in Phase 9) — the schema and
capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/ lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See
034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql` `platform/api/migrations/034_phase7_offerings_registrations_agreements.sql`,
for the exact DDL and `platform/api/src/offerings.js` for the implementation. `035_phase8_enrollment_operations.sql`, and `037_phase9_commerce.sql` for the exact DDL and
`platform/api/src/offerings.js`/`commerce.js` for the implementation.
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/ **Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
full/window-closed offerings reject registration attempts with distinct error codes — see full/window-closed offerings reject registration attempts with distinct error codes — see
@@ -14,6 +15,14 @@ registration integration (`POST /api/integrations/registrations`) is live — se
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`. (`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
**Updated for Phase 9**: an offering can carry a real price (`price_amount`/`price_currency`); a
paid offering's registration reserves its seat with a bounded expiry
(`reservation_expires_at`) instead of holding it forever; agreement acceptance is now reachable
from the public registration contract (closing Phase 8's one open gap); and a verified payment
webhook — never a browser redirect — is what actually confirms a registration and activates the
enrollment. See "Pricing" and "Payment and the commerce boundary" below, and `docs/
PHASE9_COMMERCE_AND_LAUNCH.md` for the full phase report.
## Offering lifecycle (server-defined transitions only) ## Offering lifecycle (server-defined transitions only)
``` ```
@@ -77,6 +86,16 @@ Not every offering needs every stage — a simple self-paced offering can go `st
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass `confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal. through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
**Phase 9** — for a PRICED offering, a verified `checkout.session.completed` (or the test
provider's equivalent) webhook event is what actually walks the registration through whichever
of `submitted`/`agreements_pending`/`reviewed`/`payment_pending` it's currently sitting in,
straight to `confirmed`, using the exact same `transitionRegistration()` table every other path
already enforces — never a shortcut that skips a real state. A failed payment does NOT cancel
the registration or release its seat (the registrant can retry checkout against the same
reservation); only an abandoned/expired checkout session, or the reservation reaper timing out an
un-paid reservation, cancels the registration and releases the seat. See "Payment and the
commerce boundary" below.
A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created
student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real
academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/ academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/
@@ -129,8 +148,43 @@ For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points a
up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced` up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced`
offering simply has `cohort_id = NULL`; there is no cohort to join. offering simply has `cohort_id = NULL`; there is no cohort to join.
## Pricing (Phase 9)
`offerings.price_amount` (integer minor units — cents, matching Stripe's own convention;
`NULL` = free/no checkout) and `offerings.price_currency` (default `usd`), both editable via
`PATCH /api/admin/offerings/:id`. A registration's checkout amount always comes from the
offering's OWN server-side price at checkout-creation time — never from anything the browser or
website backend supplies (`commerce.createCheckoutSession()` reads it directly from the
`offerings` row; there is no `amount` field anywhere in the checkout request the website sends).
`computeOfferingReadiness()` gained matching `price` and `payment_provider` checks — a priced
offering isn't launch-ready until a real price is set and the configured payment provider is
actually usable (the test provider always is; Stripe requires both `STRIPE_SECRET_KEY` and
`STRIPE_WEBHOOK_SECRET` to be set).
## Payment and the commerce boundary (Phase 9)
**Payment is a narrow, provider-agnostic layer bolted onto the existing registration model, not
a redesign of it.** `payment_transactions` (one row per checkout attempt: amount/currency
snapshotted at creation, provider, provider_reference, status) and `payment_webhook_events` (one
row per provider event id — the ENTIRE webhook idempotency mechanism, a real unique primary key,
not a "check then insert") are the only two new tables. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md`
for the payment-provider abstraction, the webhook contract, and the reservation-reaper design in
full, and `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own checkout contract.
**Three separately-tracked statuses, never auto-cascading into each other:** the registration's
own `status` (this document), the enrollment's own `status` (unchanged, `enrollment.js`), and a
payment transaction's own `status` (`payment_transactions.status`). A refund updates ONLY the
transaction's status — it never automatically cancels the registration or the enrollment; an
admin decides separately whether a refund also means "this student is no longer enrolled,"
because the two are genuinely different real-world decisions (a partial-camp refund for a missed
day is not the same decision as pulling a student's access).
## What was deliberately not built ## What was deliberately not built
Payment processing, a public-facing registration form, pricing display, and subscription Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for plans, complex tax automation, and accounting-system (QuickBooks etc.) integration — all
future descriptive pricing/marketing fields, but nothing writes payment data into it. explicitly out of scope, per Phase 9's own authorization. A waitlist was considered and
deliberately deferred (see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`'s "What was deliberately not
built" section for the reasoning). `offerings.metadata` (JSONB) still has room for future
descriptive marketing fields, but nothing writes payment data into it — payment data lives only
in `payment_transactions`.
+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
@@ -98,15 +98,19 @@ unchanged). A registration becomes a real enrollment only once `confirmed`, via
`agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal `agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal
text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`. text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
## Commerce boundary — unchanged, extended in documentation only ## Commerce boundary — real as of Phase 9
The existing boundary (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) stands: the website owns The boundary itself is unchanged: the website owns marketing/pricing DISPLAY and the hosted
marketing/pricing/checkout, RootNode's platform owns everything after a verified commerce event. checkout experience, RootNode's platform owns the actual price (server-controlled, never
Phase 7 adds a documented FUTURE flow (website → offering selection → registration → checkout → browser-supplied), payment verification, and everything after a verified commerce event. Phase 7
verified commerce event → registration confirmed → enrollment activated) without implementing documented the flow; **Phase 9 built it for real**: an offering can carry a price
any payment code. **BUILT NOW**: the existing `/api/integrations/enrollments` endpoint, (`offerings.price_amount`/`price_currency`), a payment-provider abstraction (Stripe or a
unchanged. **PHASE 7 BUILT**: the registration/offering model that future flow will plug into. deterministic in-process test provider — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`) creates a
**FUTURE**: an actual commerce-event webhook confirming a registration. hosted checkout session server-side, and a verified, idempotent webhook is the ONLY thing that
ever confirms payment and drives `registration confirmed → enrollment activated` — a browser
`?paid=true` redirect proves nothing and is never trusted. See `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's contract and `docs/
STRIPE_INTEGRATION_RUNBOOK.md` for going live with real Stripe.
## First commercial target: Winter Break Camp ## First commercial target: Winter Break Camp
@@ -141,6 +145,13 @@ planned this phase.
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT | | Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) | | Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT | | Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
| Public registration flow (a real website-side form), payments, subscriptions | FUTURE | | Offering pricing, payment-provider abstraction, hosted checkout, verified webhook | **PHASE 9 BUILT** (`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) — Stripe test-mode + a deterministic in-process test provider; real Stripe live-mode validation is pending real credentials. |
| Agreement acceptance on the public registration contract | **PHASE 9 BUILT** — closes the gap Phase 8 left open. |
| Refund/cancellation recording (commerce status only, never auto-cascading) | PHASE 9 BUILT |
| Abandoned-reservation reaper, reservation expiry for paid offerings | PHASE 9 BUILT |
| Admin payment UI (price, payment/refund status, no card data) | PHASE 9 BUILT |
| A real website-side registration/checkout form (rootnodeacademy.com itself) | FUTURE — contract ready, form not built in this repo (separate repo, not RootNode-owned). |
| Live Stripe validation (real credentials, real card, real webhook from Stripe's servers) | FUTURE — application layer is done and Stripe-test-mode-tested; pending real Stripe credentials. |
| Subscriptions, membership billing, coupons, gift cards, installment plans, tax automation | FUTURE, explicitly out of scope |
| INIT_/COMPILE_ curriculum | FUTURE | | INIT_/COMPILE_ curriculum | FUTURE |
| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope | | School/library SIS integrations, parent portal, guardian login | FUTURE, explicitly out of scope |
+134
View File
@@ -0,0 +1,134 @@
# Stripe Integration Runbook
Operational reference for RootNode's payment-provider abstraction (`platform/api/src/
payment-provider.js`) — configuration, local testing against both the deterministic test
provider and real Stripe test mode, webhook setup, and what's left before a real, live-money
launch. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md` for the design/architecture story and
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own contract.
## The two providers
| | `PAYMENT_PROVIDER=test` (default) | `PAYMENT_PROVIDER=stripe` |
|---|---|---|
| Network calls | None — fully in-process | Real calls to Stripe's API |
| Needs credentials | No | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
| Checkout URL | A fake URL pointing back at your own `success_url` | A real Stripe-hosted Checkout page |
| Webhook signature | A static shared secret (`TEST_PAYMENT_WEBHOOK_SECRET`, header `X-Test-Payment-Signature`) | Real Stripe HMAC signature (header `Stripe-Signature`), verified via Stripe's own SDK |
| Real money | Never | Real in live mode; fake (test cards) in Stripe's own test mode |
| Used by | Every automated test/rehearsal in this repo | Nothing yet — pending real credentials |
`commerce.js` and every route above it call the SAME methods regardless of which provider is
active (`createCheckoutSession`, `verifyAndParseWebhook`) — switching providers is an environment
variable, never a code change.
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `PAYMENT_PROVIDER` | `test` | `test` or `stripe`. |
| `STRIPE_SECRET_KEY` | unset | Required for `stripe`. Server-side only — never exposed to any browser-served code. |
| `STRIPE_WEBHOOK_SECRET` | unset | Required for `stripe`. The signing secret Stripe gives you for a specific webhook endpoint (dashboard or `stripe listen`, see below) — NOT your API secret key. |
| `TEST_PAYMENT_WEBHOOK_SECRET` | `test-payment-secret-not-for-production` | Only used by the `test` provider. Change if you need to isolate parallel test runs; otherwise the default is fine. |
| `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` | `30` | How long a paid offering's seat stays reserved before the reaper releases it if checkout is never completed. |
| `RESERVATION_REAPER_POLL_MS` | `60000` | How often the reservation reaper checks for expired, unpaid reservations. |
None of these belong in `frontend/` or anywhere a browser can read them — the Stripe secret key
and webhook secret are server-only, exactly like `INTEGRATION_SHARED_SECRET`/
`PROVISIONER_SHARED_SECRET` already are in this codebase.
## Local testing against the test provider (no Stripe account needed)
This is what every e2e suite and rehearsal in this repo actually runs against. Leave
`PAYMENT_PROVIDER` unset (defaults to `test`) and drive the full cycle directly:
```bash
# 1. Create a checkout session (server-to-server, same as the website would)
curl -s -X POST http://localhost:4200/api/integrations/registrations/<id>/checkout \
-H 'Content-Type: application/json' \
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
-d '{"success_url":"https://example.com/s","cancel_url":"https://example.com/c"}'
# 2. Look up the provider_reference the checkout just created
docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform \
-c "SELECT provider_reference FROM payment_transactions WHERE registration_id = '<id>' ORDER BY created_at DESC LIMIT 1;"
# 3. Build and POST a fake "payment succeeded" webhook event (Node, using the same provider
# module the real server uses — see platform/test/e2e-phase9.js for the full pattern)
node -e "
const { getPaymentProvider } = require('./platform/api/src/payment-provider');
const p = getPaymentProvider();
const event = p.buildTestEvent({ providerReference: '<provider_reference from step 2>', eventType: 'checkout.session.completed' });
console.log(JSON.stringify(event));
"
curl -s -X POST http://localhost:4200/api/webhooks/payment \
-H 'Content-Type: application/json' \
-H "X-Test-Payment-Signature: <event.signature from above>" \
-d '<event.body from above>'
```
To simulate a **failed payment** or **abandoned checkout**, use `eventType:
'payment_intent.payment_failed'` or `'checkout.session.expired'` in step 3 instead — see
`platform/test/e2e-phase9.js` for both, and for the reservation-reaper path (never send a webhook
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
test).
## Local testing against real Stripe TEST mode (once credentials exist)
Not yet exercised in this repo — no Stripe account/credentials have been provided as of this
phase. Once they exist:
1. Create (or use an existing) Stripe account, switch to **Test mode** in the dashboard.
2. Copy the test-mode **Secret key** (`sk_test_...`) into `STRIPE_SECRET_KEY`.
3. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) and run:
```bash
stripe login
stripe listen --forward-to localhost:4200/api/webhooks/payment
```
The CLI prints a webhook signing secret (`whsec_...`) — set that as `STRIPE_WEBHOOK_SECRET`
for the duration of the local session (it's ephemeral per `stripe listen` run; a real
deployment instead creates a permanent webhook endpoint in the Stripe dashboard and uses ITS
signing secret).
4. Set `PAYMENT_PROVIDER=stripe` and restart the API.
5. Drive the checkout flow through steps 2-4 of `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` — you
now get back a REAL `checkout_url`. Open it in a browser and pay with a Stripe test card
(`4242 4242 4242 4242`, any future expiry, any CVC, any ZIP).
6. `stripe listen` forwards Stripe's real webhook event to your local server; watch the server
log and `payment_webhook_events` table for `processed`.
7. To simulate a failure without a real failing card, use Stripe's documented test card numbers
for declines (e.g. `4000 0000 0000 0002`), or `stripe trigger payment_intent.payment_failed`.
This is the recommended path to real validation before going live — it exercises the actual
Stripe SDK, actual signature verification, and actual Stripe event delivery, with zero real
money at risk.
## Going live (real money)
1. Switch the Stripe dashboard to **Live mode**, generate a live **Secret key**
(`sk_live_...`), and create a permanent webhook endpoint pointed at the production API's
`/api/webhooks/payment` URL — copy ITS signing secret (`whsec_...`, different from test mode's).
2. Set `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) and `PAYMENT_PROVIDER=stripe` in
production configuration only — never commit these to git, never put them in a `.env` file
that gets version-controlled (matching this repo's existing secret-handling convention for
`PROVISIONER_SHARED_SECRET`/`INTEGRATION_SHARED_SECRET`).
3. Set a real price on the live offering (`PATCH /api/admin/offerings/:id`) and confirm
`GET /api/admin/offerings/:id/readiness` shows `payment_provider: ready`.
4. Run one real, small, genuinely-refundable transaction against a real card before opening
registration publicly, and confirm the full pipeline (checkout → webhook → confirmation →
enrollment → activation email) end to end, then refund it via the admin UI or the Stripe
dashboard directly.
5. Monitor `rootnode_payment_webhooks_total`/`rootnode_payments_total` (Prometheus metrics, see
`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) after opening registration.
**This phase does not perform steps 1-4 above** — no real Stripe credentials were provided, and
inventing/obtaining them is a business decision reserved for Chase, not something this phase
invents. Everything up to this runbook is built and tested against the test provider; live
validation is the one explicitly pending item.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `503 PROVIDER_NOT_CONFIGURED` on checkout | `PAYMENT_PROVIDER=stripe` but `STRIPE_SECRET_KEY` unset, or the `stripe` npm package failed to load. |
| `400 invalid_signature` on every real Stripe webhook | `STRIPE_WEBHOOK_SECRET` doesn't match the endpoint Stripe actually delivered to (test-mode secret used against a live endpoint, or vice versa — each `stripe listen`/dashboard endpoint has its OWN secret). |
| Webhook received but nothing changes | Check `payment_webhook_events.result` for that event id — `duplicate` means it was already processed (expected on a retry); check server logs for an exception if `result` is missing entirely. |
| Seat never releases after an abandoned checkout | Confirm the reservation reaper is running (`RESERVATION_REAPER_POLL_MS` log line on server startup) and that `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` hasn't been set unreasonably high. |
+57 -22
View File
@@ -212,14 +212,21 @@ appear; `draft` and `cancelled` never do. Example response:
"registration_open": true, "registration_open": true,
"availability": "limited", "availability": "limited",
"location_name": "Community Room B", "location_name": "Community Room B",
"location_type": "community_center" "location_type": "community_center",
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
} }
``` ```
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting `availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
scraping/timing games against a public endpoint). No internal notes, no organization contact scraping/timing games against a public endpoint). No internal notes, no organization contact
info, no instructor identity, no pricing — the website owns pricing display entirely; this info, no instructor identity — just enough to build a pricing/availability display. **As of Phase
endpoint doesn't return it. 9**: `price_amount` is an integer in minor currency units (cents — `24900` = $249.00), `null` for
a free offering; `price_currency` is a lowercase ISO code (`"usd"`); `requires_payment` is a
plain derived boolean (`price_amount !== null`) so the website never has to reimplement that
check. The actual checkout amount is always read server-side from this same offering row at
checkout-creation time — the website never sends an amount anywhere in this contract.
### 2. Select an offering, then submit a registration (server-to-server) ### 2. Select an offering, then submit a registration (server-to-server)
@@ -285,35 +292,63 @@ required; `phone` optional). RootNode matches an existing guardian by email firs
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
guardian's email is never written to the eventual student's own identity record. guardian's email is never written to the eventual student's own identity record.
### 6. Agreement flow ### 6. Agreement flow (Phase 9 — live)
Not yet exposed on this endpoint — an agreement acceptance is currently recorded by RootNode ```
staff (or a future self-service step) after the registration is submitted, before it can be POST /api/integrations/registrations/:id/agreements
confirmed. If your registration flow collects agreement acceptance at submission time, talk to Body: { "agreement_id": "winter-camp-waiver-2026" }
the RootNode team about extending this contract before building around a workaround. ```
### 7. Future payment step Same shared-secret header as every other route in this section. Which party accepts is
server-derived from the registration's own `is_minor`, never a value you send: an adult accepts
as themselves (before their student account even exists yet), a minor's registration is accepted
by the guardian already on file from submission. `201 { "accepted": true, "agreement_id": ...,
"accepted_version": ... }` on success; `404 AGREEMENT_NOT_FOUND` if the id doesn't match a real
agreement configured on this offering. **Checkout is blocked (`409 AGREEMENTS_INCOMPLETE`) until
every required agreement is accepted** — call this before calling checkout below.
Not built. Today, a registration is manually moved to `confirmed` by RootNode staff once payment ### 7. Checkout (Phase 9 — live)
is verified through whatever process exists outside the platform. A future phase's real payment
integration would do this automatically on a verified commerce event.
### 8. Confirmation ```
POST /api/integrations/registrations/:id/checkout
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Once `confirmed` (manually today, automatically in a future payment-integrated phase) and every Server-to-server only — the browser never calls this directly, and never supplies a price. The
required agreement is accepted, the registration can convert to a real enrollment. amount, currency, and product description all come from the offering's own server-side
configuration. Success: `201 { "checkout_url": "...", "transaction_id": "..." }` — redirect the
customer's browser to `checkout_url` (RootNode's configured payment provider's own hosted
checkout page; today that's Stripe Checkout in test mode, or a fake URL from the deterministic
test provider in local dev — see `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`). A free offering
(`price_amount: null`) returns `409 NO_PRICE_CONFIGURED` — never call this for a free offering;
just proceed straight to confirmation. Full error table, the success-page design (never trust a
returning `?paid=true`), and the status-lookup endpoint are in `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md`.
### 9. Enrollment activation ### 8. Payment confirmation — a verified webhook, never a browser redirect
Conversion creates (or matches) the real student account and a real academic enrollment, reusing RootNode's own webhook (not something the website calls) is the ONLY thing that confirms
the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real
second activation path exists. enrollment automatically — reusing the exact activation-email flow described below, no separate
"payment succeeded" email exists. See `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for how to
build a safe success page against this (poll the status-lookup endpoint; never derive success
from the URL alone).
### 9. Confirmation and enrollment activation
Once `confirmed` (via the webhook above for a priced offering, or a manual admin transition for
a free one) and every required agreement is accepted, the registration converts to a real
enrollment — creating or matching the real student account and reusing the exact same
activation-email machinery `/api/integrations/enrollments` already triggers. No second
confirmation path and no second activation path exist.
### 10. What this endpoint does NOT do ### 10. What this endpoint does NOT do
No payment-card data (never sent, never has a field for it). No password (RootNode issues its No payment-card data (never sent, never has a field for it — see `docs/
own credential/activation flow). No arbitrary registration status (server-defined transitions WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password
only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example data above is fake — do not (RootNode issues its own credential/activation flow). No arbitrary registration status
(server-defined transitions only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example
data above is fake — do not
reuse these values against a real deployment. reuse these values against a real deployment.
## What the student experiences after you call this (as of Phase 5C) ## What the student experiences after you call this (as of Phase 5C)
+210
View File
@@ -0,0 +1,210 @@
# Website → RootNode Registration & Checkout
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
(Winter Break Camp and anything after it). This is the Phase 9 companion to
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
the success page, and status lookup.
## The end-to-end flow
```
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
2. POST /api/integrations/registrations — submit the registration (server-to-server)
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
5. redirect the customer's browser to the returned checkout_url
6. RootNode's webhook confirms payment — NOT something the website calls
7. GET /api/integrations/registrations/:id/status — poll from the success page
```
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
browser directly.
## 1. Price and availability display
```
GET /api/public/offerings/:slug
```
No auth needed. As of Phase 9, the response includes:
```json
{
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
**This price is display-only.** The actual amount charged always comes from the SAME offering
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
contract for the website to submit an amount. A browser (or a compromised website backend)
cannot pay a different price than what's configured on the offering.
## 2. Submit the registration
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
## 3. Accept the agreement(s)
```
POST /api/integrations/registrations/:id/agreements
Header: X-RootNode-Integration-Key: <secret>
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
Which party is recorded as accepting is **server-derived from the registration's own
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
```json
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
```
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
from step 4). Call this before checkout, always.
## 4. Create the checkout session
```
POST /api/integrations/registrations/:id/checkout
Header: X-RootNode-Integration-Key: <secret>
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
is no `amount` field — see "Price and availability display" above.
Success (`201`):
```json
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
```
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
`window.location`). In local/staging use with the deterministic test provider (see
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
to visit, which is fine for automated testing but means you can't manually click through a
"real" checkout page against the test provider.
**Errors:**
| Status | `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong integration key. |
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
## 5. Payment confirmation — never trust the redirect
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
enrolled" or grant anything based on the URL/query string alone.**
## 6. Building a safe success page
```
GET /api/integrations/registrations/:id/status
Header: X-RootNode-Integration-Key: <secret>
```
Call this from your OWN backend (never expose the integration key to the browser) when the
customer lands on your success page, and poll it every couple of seconds for a short window (the
webhook is normally processed within a second or two of the customer completing checkout — see
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
```json
{
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
"status": "confirmed",
"payment_status": "paid",
"enrollment_activated": false
}
```
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
of three states on your success page:
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
your email for your RootNode Academy activation link."
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
payment — this page will update automatically" (keep polling, or tell the customer to check
back / check email shortly).
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
priced offering, a failed payment does NOT release the seat, so the SAME registration can
retry checkout (back to step 4) without losing its place.
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
"expired" webhook processes it — at that point the seat has been released and the customer needs
to start a new registration if they still want in.
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
## 7. What you must NOT do
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
Stripe's hosted Checkout page collects it directly.
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
add one to your own request body it is silently ignored (the price always comes from the
offering).
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
fires automatically on webhook-confirmed conversion (same machinery `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
duplicate or conflict with it.
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
customer to check their email and stop polling.
## Accessibility and loading-state notes
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
return).
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
the customer looking at a page that appears frozen while you poll.
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
map each to a real, specific message rather than one generic "something went wrong."
## Test-mode flow (what to actually test against)
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
for a true end-to-end rehearsal before anything goes live with real money.
## Privacy note
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
already documents for registration (names, emails, phone) — nothing payment-specific is added.
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
provider reference — never card data).
+50 -35
View File
@@ -1,24 +1,31 @@
# Winter Break Camp Operations # Winter Break Camp Operations
Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp Phase 8 §29 (payment step updated in Phase 9) — how to actually run the Winter Break Linux &
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases Systems Foundations Camp (`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the
7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers platform built in Phases 7-9, end to end, entirely from `/admin`. As of Phase 9, real
everything up to and including the point where a real payment/checkout would plug in. checkout/payment (Stripe test mode today, live mode once real credentials exist) is part of this
flow — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md` and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`.
## What already exists vs. what Chase still fills in ## What already exists vs. what Chase still fills in
**Already representable, real, tested:** **Already representable, real, tested:**
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses). - The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
- The offering itself, in DRAFT, with delivery mode `camp`. - The offering itself, in DRAFT, with delivery mode `camp`, and a real price.
- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort → - Registration → guardian/adult → agreement → real checkout → verified webhook → confirmation →
activation → classroom → real Lab work, proven end to end in enrollment → cohort → activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing). `infra/scripts/drills/test-phase9-winter-camp-commerce.js` (13/13 passing, test payment
provider, no live money) — plus failed-payment retry, abandoned-checkout seat release, and a
last-seat checkout race, none of which existed in Phase 8's own rehearsal.
**Still genuinely unknown, deliberately left editable, never invented by this phase:** **Still genuinely unknown, deliberately left editable, never invented by this phase:**
- Exact dates, venue, real capacity, and price. - Exact dates, venue, real capacity, and price (the price FIELD is real and editable; the actual
dollar amount to charge is still Chase's call).
- The actual agreement text (a plain-language placeholder only — see `docs/ - The actual agreement text (a plain-language placeholder only — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list). STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- Whether registration opens before or after a specific marketing push. - Whether registration opens before or after a specific marketing push.
- Real Stripe credentials — until they exist, the platform runs on the deterministic test
provider, functionally complete but not yet validated against Stripe's own live systems. See
`docs/STRIPE_INTEGRATION_RUNBOOK.md`.
## The operational sequence ## The operational sequence
@@ -35,40 +42,48 @@ everything up to and including the point where a real payment/checkout would plu
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7) 4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
page's own instructor step, or the two admin routes directly. page's own instructor step, or the two admin routes directly.
5. **Configure the required agreement(s)** — create the agreement definition (title + plain- 5. **Set the price** — `PATCH /api/admin/offerings/:id` with `price_amount`/`price_currency`
(or leave `price_amount` unset for a free offering). Confirm `PAYMENT_PROVIDER` is configured
(`test` for rehearsal, `stripe` with real credentials for a live launch — see `docs/
STRIPE_INTEGRATION_RUNBOOK.md`).
6. **Configure the required agreement(s)** — create the agreement definition (title + plain-
language summary) and link it to the offering. **The actual legal text still needs language summary) and link it to the offering. **The actual legal text still needs
professional review before this camp is opened to real families** — see below. professional review before this camp is opened to real families** — see below.
6. **Check launch readiness** — the offering detail page's readiness panel should show every 7. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing. If instructor/curriculum/agreements are configured but dates/ check green before publishing, including the new `price` and `payment_provider` checks. If
location/capacity aren't final yet, readiness will honestly show that. instructor/curriculum/agreements/price are configured but dates/location/capacity aren't
7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once final yet, readiness will honestly show that.
8. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
`enrollment_open` does the offering appear in the public offerings API and accept real `enrollment_open` does the offering appear in the public offerings API and accept real
registrations. registrations.
8. **Registrations arrive** — via the website integration (once wired — see 9. **Registrations arrive** — via the website integration (contract ready, see
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`) or entered directly by an admin for a phone/
admin for a phone/in-person sign-up. in-person sign-up.
9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement 10. **Guardian/adult completes the agreement, then checkout** — the agreement acceptance and
acceptance action. checkout-creation endpoints are both live (Phase 9); checkout is blocked until the agreement
10. **Payment step (future, not built)** — this is the one step this phase does not implement. is accepted.
Today, an admin manually transitions a registration to `confirmed` once payment is verified 11. **Payment confirmation** — a verified, idempotent webhook (never a browser redirect) is what
by whatever process exists outside the platform; a future phase's real payment integration actually confirms payment and walks the registration to `confirmed`. A failed payment leaves
would do this automatically on a verified commerce event, the same shape the registration retriable without releasing the seat; an abandoned checkout or an expired,
`/api/integrations/enrollments` already uses for direct program enrollment. unpaid reservation releases it.
11. **Convert to enrollment** — creates the real student account (or matches an existing one), 12. **Convert to enrollment** — happens automatically on payment confirmation for a priced
the real academic enrollment, and real cohort membership. offering (or a manual admin transition for a free one) — creates the real student account
12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time (or matches an existing one), the real academic enrollment, and real cohort membership.
13. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
activation token, delivered the same way every other RootNode activation email already is. activation token, delivered the same way every other RootNode activation email already is.
13. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join 14. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
and work through real ROOT 01-02 content and Labs. and work through real ROOT 01-02 content and Labs.
14. **Offering completion** — `in_progress → completed` once the camp concludes. 15. **Offering completion** — `in_progress → completed` once the camp concludes.
## Known blockers before this can actually open to the public ## Known blockers before this can actually open to the public
- Real dates, venue, capacity, and price are not yet decided (this document does not invent - Real dates, venue, capacity, and the actual dollar price are not yet decided (this document
them). does not invent them — the platform fields to hold them are real and ready).
- The agreement text requires professional legal review (see `docs/ - The agreement text requires professional legal review (see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`). STUDENT_GUARDIAN_DATA_MODEL.md`).
- No payment processing exists — step 10 above is manual until a future phase builds it. - Real Stripe credentials do not exist yet — the commerce pipeline is built and tested against
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com the deterministic test provider only; see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for going live.
is built separately) — the integration contract it needs is ready - The website's own registration/checkout UI does not exist yet in this repository
(`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet. (rootnodeacademy.com is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`), but nobody has built the website-side form yet.
- See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full go/no-go list.
+75
View File
@@ -0,0 +1,75 @@
# Winter Break Camp — Launch Checklist
The single go/no-go list for opening the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) to real public registration. Combines
`docs/WINTER_BREAK_CAMP_OPERATIONS.md`'s operational sequence with Phase 9's commerce
requirements into one checklist Chase can walk top to bottom. Nothing on this list is invented by
the platform — every unchecked item is a real decision or asset that has to come from Chase, the
legal reviewer, or Stripe, not from more code.
## Platform readiness — done, verified, ready whenever the business inputs below land
- [x] Offering, capacity, registration lifecycle, agreements — built and tested (Phase 7/8).
- [x] Offering pricing field, payment-provider abstraction, hosted checkout, verified webhook,
refund recording, abandoned-reservation reaper — built and tested against the deterministic
test provider (Phase 9).
- [x] Website registration + checkout + agreement + status-lookup contract — documented and
tested (`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`).
- [x] Admin UI for offering pricing and per-registration payment/refund status.
- [x] `GET /api/admin/offerings/:id/readiness` reflects real configuration state, including the
new `price` and `payment_provider` checks.
- [x] Full Winter Break commerce rehearsal green end to end against a real, freshly-built stack
(`infra/scripts/drills/test-phase9-winter-camp-commerce.js`, 13/13): admin configuration,
minor and adult registration, checkout, payment success, webhook, enrollment activation, a
real classroom session, a real Lab task, failed payment, duplicate webhook, abandoned
checkout, and a last-seat race.
## Business decisions — Chase's call, not invented by this phase
- [ ] Real camp dates and duration.
- [ ] Real venue/location (or confirm fully online/remote).
- [ ] Real capacity (seat count).
- [ ] Real price and currency.
- [ ] Whether registration opens ahead of, or alongside, a specific marketing push.
- [ ] Refund policy in plain terms (the platform can RECORD any refund decision; it does not
decide policy — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s "Payment and the commerce
boundary" section for what "record, never auto-cascade" means operationally).
## Legal review — required before any real family sees the agreement
- [ ] The actual participation/media/liability agreement text, reviewed by a real attorney
(`agreement_definitions.body_summary` is currently a plain-language placeholder only — see
`docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- [ ] Jurisdiction-specific minor-consent requirements confirmed.
- [ ] Data-retention/deletion policy decided and, if needed, reflected in the agreement text.
- [ ] A decision on whether emergency-contact fields need additional encryption at rest.
## Payments — pending real Stripe credentials
- [ ] Real Stripe account created/available (live mode).
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
configuration only — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section.
- [ ] At least one real, small, refundable test transaction run end to end in production before
opening registration publicly, then refunded.
- [ ] Recommended (not required — the test provider already validates the application logic):
one full rehearsal against Stripe's own TEST mode (fake cards, no real money) before
flipping to live credentials, per `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s test-mode section.
## Website (separate repo, not RootNode-owned)
- [ ] rootnodeacademy.com's own registration/checkout form built against
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`. Not tracked or built in this repository — if
that repo becomes available to RootNode's own engineering effort, say so explicitly before
any code changes are made against it.
- [ ] The website's `INTEGRATION_SHARED_SECRET` provisioned into its OWN backend configuration
(never browser-reachable) — see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s "Production
secret handling."
- [ ] The website's success page built against the status-lookup polling pattern in
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` §6 — never derived from the redirect URL alone.
## Final go/no-go
Registration should not open to the public until every box above is checked. The platform side
(first section) has been true since this phase shipped; everything else is a business, legal, or
external-team dependency this phase deliberately did not — and should not — invent on Chase's
behalf.