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).
191 lines
12 KiB
Markdown
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`.
|