From 80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Thu, 27 Aug 2026 09:06:04 -0500 Subject: [PATCH] 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). --- docs/OFFERING_AND_REGISTRATION_MODEL.md | 68 ++++- docs/PHASE9_COMMERCE_AND_LAUNCH.md | 276 ++++++++++++++++++ ...ADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md | 31 +- docs/STRIPE_INTEGRATION_RUNBOOK.md | 134 +++++++++ docs/WEBSITE_ENROLLMENT_INTEGRATION.md | 79 +++-- docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md | 210 +++++++++++++ docs/WINTER_BREAK_CAMP_OPERATIONS.md | 85 +++--- docs/WINTER_BREAK_LAUNCH_CHECKLIST.md | 75 +++++ 8 files changed, 884 insertions(+), 74 deletions(-) create mode 100644 docs/PHASE9_COMMERCE_AND_LAUNCH.md create mode 100644 docs/STRIPE_INTEGRATION_RUNBOOK.md create mode 100644 docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md create mode 100644 docs/WINTER_BREAK_LAUNCH_CHECKLIST.md diff --git a/docs/OFFERING_AND_REGISTRATION_MODEL.md b/docs/OFFERING_AND_REGISTRATION_MODEL.md index bc03973..e8468a2 100644 --- a/docs/OFFERING_AND_REGISTRATION_MODEL.md +++ b/docs/OFFERING_AND_REGISTRATION_MODEL.md @@ -1,9 +1,10 @@ # Offering & Registration Model -Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8) — the schema and lifecycle for offerings, -capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/ -034_phase7_offerings_registrations_agreements.sql` and `035_phase8_enrollment_operations.sql` -for the exact DDL and `platform/api/src/offerings.js` for the implementation. +Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8, extended again in Phase 9) — the schema and +lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See +`platform/api/migrations/034_phase7_offerings_registrations_agreements.sql`, +`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/ 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 (`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) ``` @@ -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 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 student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real 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` 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 -Payment processing, a public-facing registration form, pricing display, and subscription -billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for -future descriptive pricing/marketing fields, but nothing writes payment data into it. +Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing +plans, complex tax automation, and accounting-system (QuickBooks etc.) integration — all +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`. diff --git a/docs/PHASE9_COMMERCE_AND_LAUNCH.md b/docs/PHASE9_COMMERCE_AND_LAUNCH.md new file mode 100644 index 0000000..df1a4b0 --- /dev/null +++ b/docs/PHASE9_COMMERCE_AND_LAUNCH.md @@ -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 diff --git a/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md b/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md index 66ac58d..a4f349c 100644 --- a/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md +++ b/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md @@ -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 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 -marketing/pricing/checkout, RootNode's platform owns everything after a verified commerce event. -Phase 7 adds a documented FUTURE flow (website → offering selection → registration → checkout → -verified commerce event → registration confirmed → enrollment activated) without implementing -any payment code. **BUILT NOW**: the existing `/api/integrations/enrollments` endpoint, -unchanged. **PHASE 7 BUILT**: the registration/offering model that future flow will plug into. -**FUTURE**: an actual commerce-event webhook confirming a registration. +The boundary itself is unchanged: the website owns marketing/pricing DISPLAY and the hosted +checkout experience, RootNode's platform owns the actual price (server-controlled, never +browser-supplied), payment verification, and everything after a verified commerce event. Phase 7 +documented the flow; **Phase 9 built it for real**: an offering can carry a price +(`offerings.price_amount`/`price_currency`), a payment-provider abstraction (Stripe or a +deterministic in-process test provider — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`) creates a +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 @@ -141,6 +145,13 @@ planned this phase. | 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`) | | 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 | -| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope | +| School/library SIS integrations, parent portal, guardian login | FUTURE, explicitly out of scope | diff --git a/docs/STRIPE_INTEGRATION_RUNBOOK.md b/docs/STRIPE_INTEGRATION_RUNBOOK.md new file mode 100644 index 0000000..1e86e1c --- /dev/null +++ b/docs/STRIPE_INTEGRATION_RUNBOOK.md @@ -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//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 = '' 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: '', 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: " \ + -d '' +``` + +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. | diff --git a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md index ac923d6..31c84e3 100644 --- a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md +++ b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md @@ -212,14 +212,21 @@ appear; `draft` and `cancelled` never do. Example response: "registration_open": true, "availability": "limited", "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 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 -endpoint doesn't return it. +info, no instructor identity — just enough to build a pricing/availability display. **As of Phase +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) @@ -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 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 -confirmed. If your registration flow collects agreement acceptance at submission time, talk to -the RootNode team about extending this contract before building around a workaround. +``` +POST /api/integrations/registrations/:id/agreements +Body: { "agreement_id": "winter-camp-waiver-2026" } +``` -### 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 -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. +### 7. Checkout (Phase 9 — live) -### 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 -required agreement is accepted, the registration can convert to a real enrollment. +Server-to-server only — the browser never calls this directly, and never supplies a price. The +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 -the exact same activation-email machinery `/api/integrations/enrollments` already triggers — no -second activation path exists. +RootNode's own webhook (not something the website calls) is the ONLY thing that confirms +payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real +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 -No payment-card data (never sent, never has a field for it). No password (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 +No payment-card data (never sent, never has a field for it — see `docs/ +WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password +(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. ## What the student experiences after you call this (as of Phase 5C) diff --git a/docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md b/docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md new file mode 100644 index 0000000..5da7369 --- /dev/null +++ b/docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md @@ -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: +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: +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: +``` + +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). diff --git a/docs/WINTER_BREAK_CAMP_OPERATIONS.md b/docs/WINTER_BREAK_CAMP_OPERATIONS.md index 7ef0de3..a68679e 100644 --- a/docs/WINTER_BREAK_CAMP_OPERATIONS.md +++ b/docs/WINTER_BREAK_CAMP_OPERATIONS.md @@ -1,24 +1,31 @@ # Winter Break Camp Operations -Phase 8 §29 — how to actually run the Winter Break Linux & Systems Foundations Camp -(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the platform built in Phases -7-8, end to end, entirely from `/admin`. No payment step exists yet — this document covers -everything up to and including the point where a real payment/checkout would plug in. +Phase 8 §29 (payment step updated in Phase 9) — how to actually run the Winter Break Linux & +Systems Foundations Camp (`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the +platform built in Phases 7-9, end to end, entirely from `/admin`. As of Phase 9, real +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 **Already representable, real, tested:** - The curriculum package (ROOT 01-02, referencing the real, unmodified courses). -- The offering itself, in DRAFT, with delivery mode `camp`. -- Registration → guardian/adult → agreement → (simulated) confirmation → enrollment → cohort → - activation → classroom → real Lab work, proven end to end in - `infra/scripts/drills/test-phase8-winter-camp-registration.js` (12/12 passing). +- The offering itself, in DRAFT, with delivery mode `camp`, and a real price. +- Registration → guardian/adult → agreement → real checkout → verified webhook → confirmation → + enrollment → cohort → activation → classroom → real Lab work, proven end to end in + `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:** -- 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/ STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list). - 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 @@ -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) and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail 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 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 - check green before publishing. If instructor/curriculum/agreements are configured but dates/ - location/capacity aren't final yet, readiness will honestly show that. -7. **Publish, then open registration** — `draft → published → enrollment_open`. Only once +7. **Check launch readiness** — the offering detail page's readiness panel should show every + check green before publishing, including the new `price` and `payment_provider` checks. If + instructor/curriculum/agreements/price are configured but dates/location/capacity aren't + 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 registrations. -8. **Registrations arrive** — via the website integration (once wired — see - `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s documented future flow) or entered directly by an - admin for a phone/in-person sign-up. -9. **Guardian/adult completes the agreement** — recorded via the registration's own agreement - acceptance action. -10. **Payment step (future, not built)** — this is the one step this phase does not implement. - Today, an admin manually transitions a registration to `confirmed` once payment is verified - by whatever process exists outside the platform; a future phase's real payment integration - would do this automatically on a verified commerce event, the same shape - `/api/integrations/enrollments` already uses for direct program enrollment. -11. **Convert to enrollment** — creates the real student account (or matches an existing one), - the real academic enrollment, and real cohort membership. -12. **Account activation** — the student's (or, for a minor, the guardian's) real one-time +9. **Registrations arrive** — via the website integration (contract ready, see + `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`) or entered directly by an admin for a phone/ + in-person sign-up. +10. **Guardian/adult completes the agreement, then checkout** — the agreement acceptance and + checkout-creation endpoints are both live (Phase 9); checkout is blocked until the agreement + is accepted. +11. **Payment confirmation** — a verified, idempotent webhook (never a browser redirect) is what + actually confirms payment and walks the registration to `confirmed`. A failed payment leaves + the registration retriable without releasing the seat; an abandoned checkout or an expired, + unpaid reservation releases it. +12. **Convert to enrollment** — happens automatically on payment confirmation for a priced + offering (or a manual admin transition for a free one) — creates the real student account + (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. -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. -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 -- Real dates, venue, capacity, and price are not yet decided (this document does not invent - them). +- Real dates, venue, capacity, and the actual dollar price are not yet decided (this document + does not invent them — the platform fields to hold them are real and ready). - The agreement text requires professional legal review (see `docs/ STUDENT_GUARDIAN_DATA_MODEL.md`). -- No payment processing exists — step 10 above is manual until a future phase builds it. -- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com - is built separately) — the integration contract it needs is ready - (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`), but nobody has built the website-side form yet. +- Real Stripe credentials do not exist yet — the commerce pipeline is built and tested against + the deterministic test provider only; see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for going live. +- The website's own registration/checkout UI does not exist yet in this repository + (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. diff --git a/docs/WINTER_BREAK_LAUNCH_CHECKLIST.md b/docs/WINTER_BREAK_LAUNCH_CHECKLIST.md new file mode 100644 index 0000000..1321aed --- /dev/null +++ b/docs/WINTER_BREAK_LAUNCH_CHECKLIST.md @@ -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.