Files
rootnode-academy/docs/OFFERING_AND_REGISTRATION_MODEL.md
T
ced4568 80a5f6e457 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).
2026-08-27 09:06:04 -05:00

191 lines
12 KiB
Markdown

# Offering & Registration Model
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
`assertRegistrationOpen()`); offerings can require specific agreements before a registration may
convert to enrollment (`offering_agreement_requirements`); `accepted_by_type` gained a
`'registrant'` value for an adult self-accepting before their student_id exists; the website
registration integration (`POST /api/integrations/registrations`) is live — see `docs/
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)
```
draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen)
│ │ │ │
│ │ ├──► in_progress ──► completed
│ │ │
└──► cancelled ◄──────────────────────────────────────────────────────┘
```
No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks
a fixed transition table and rejects anything not explicitly allowed with `409
INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so
an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat
freed up) rather than have it silently reappear.
## Capacity — race-safe by construction
`offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`).
`offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement:
```sql
UPDATE offerings SET reserved_seats = reserved_seats + 1
WHERE offering_id = $1 AND reserved_seats < capacity
RETURNING *;
```
If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a
`SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the
row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration
attempts for the last seat serialize correctly, with exactly one succeeding, never both.
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
Phase 7's own testing found the route originally reserved the seat *before* validating the
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
Phase 8 then added a `registration_received` notification fired right after the registration row
was inserted — which briefly made the ORIGINAL Phase 7 fix's remaining shape (insert, then
reserve, then roll back the row by deleting it if the seat turned out to be unavailable) newly
unsafe: the notification row, created after insert but before the seat check, held a real
foreign key to the registration — so when reservation failed and the code tried to delete the
just-inserted registration row, that DELETE itself failed on the very foreign key the
notification had just created. Found via this phase's own regression testing, fixed by moving
seat reservation to happen BEFORE the insert (rolling the seat back, not a row, if the insert
itself then somehow fails) and only ever queuing the notification once the registration is fully,
durably committed — see `offerings.js`'s `createRegistration()` and its own header comment.
## Registration lifecycle
```
started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated
│ │ │ │
│ └──────────────────► confirmed ◄────────────────┘
└──► cancelled (from any non-terminal state; releases the reserved seat)
```
Not every offering needs every stage — a simple self-paced offering can go `started` →
`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`/
`createManualEnrollment` rather than duplicating student-matching or enrollment-creation logic,
and creates `cohort_enrollments` membership too if the offering has a `cohort_id`.
## Minor registrations
`offering_registrations.is_minor = true` requires a non-null `guardian_id` — enforced by a
database CHECK constraint, not just application logic, so it holds even against a direct/future
API caller that skips the usual route. The guardian is a real `guardians` row (Phase 7's new
model), never a duplicated email on the eventual student record.
## Agreements — acceptance records, not legal text
`agreement_definitions` stores a title and a plain-language `body_summary` for admin-UI display
— explicitly **not** attorney-approved legal language (see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s
legal-review list). `agreement_acceptances` snapshots the `accepted_version` at acceptance time
(not just a live foreign key to the current version), so a later edit to an agreement's text
never retroactively reinterprets what someone already agreed to — the same principle
`COURSE_CONTENT_MODEL.md` §6b already established for assessment session versioning. An
acceptance is tied to a student OR a guardian (a CHECK constraint requires at least one), and
optionally to the registration/enrollment context it was collected for.
## Curriculum packages — reference, never clone
A `curriculum_package` names a versioned subset of an existing program's courses via
`curriculum_package_items` (`course_id` + `sequence`) — a pure reference list, storing no
content of its own. Worked example, matching `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s
Option A:
```
curriculum_package_id: winter-linux-systems-v1
program_id: root-kubernetes-foundations
items: [ (root-01-computing-linux-foundations, seq 1), (root-02-linux-administration-networking, seq 2) ]
```
An offering referencing this package still points its students at the real, unmodified ROOT 01/
02 courses — nothing is cloned, no activity is duplicated, and any future content fix to ROOT 01/
02 is automatically reflected for every offering referencing the package. Historical offerings
stay stable against a **specific version** of the package (bump `curriculum_packages.version` for
a semantic change, exactly like every other versioned row in this schema) rather than silently
picking up a redefinition.
## Offering → cohort
For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points at an existing
`cohorts` row, unchanged from Phase 2A. `convertRegistrationToEnrollment()` inserts the matching
`cohort_enrollments` row when present — a student registering into a cohort-backed offering ends
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
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`.