docs(phase9): document commerce integration and Winter Break launch readiness
New: PHASE9_COMMERCE_AND_LAUNCH.md, WEBSITE_REGISTRATION_AND_CHECKOUT.md, STRIPE_INTEGRATION_RUNBOOK.md, WINTER_BREAK_LAUNCH_CHECKLIST.md. Updated: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (commerce boundary + PHASE 9 BUILT rows), OFFERING_AND_REGISTRATION_MODEL.md (pricing, the payment/commerce boundary, three-status principle), WEBSITE_ENROLLMENT_INTEGRATION.md (price fields, real agreement/checkout/webhook sections replacing Phase 8 placeholders), WINTER_BREAK_CAMP_OPERATIONS.md (real payment step replacing the manual placeholder).
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
# Website → RootNode Registration & Checkout
|
||||
|
||||
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
|
||||
(Winter Break Camp and anything after it). This is the Phase 9 companion to
|
||||
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
|
||||
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
|
||||
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
|
||||
the success page, and status lookup.
|
||||
|
||||
## The end-to-end flow
|
||||
|
||||
```
|
||||
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
|
||||
2. POST /api/integrations/registrations — submit the registration (server-to-server)
|
||||
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
|
||||
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
|
||||
5. redirect the customer's browser to the returned checkout_url
|
||||
6. RootNode's webhook confirms payment — NOT something the website calls
|
||||
7. GET /api/integrations/registrations/:id/status — poll from the success page
|
||||
```
|
||||
|
||||
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
|
||||
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
|
||||
browser directly.
|
||||
|
||||
## 1. Price and availability display
|
||||
|
||||
```
|
||||
GET /api/public/offerings/:slug
|
||||
```
|
||||
|
||||
No auth needed. As of Phase 9, the response includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"price_amount": 24900,
|
||||
"price_currency": "usd",
|
||||
"requires_payment": true
|
||||
}
|
||||
```
|
||||
|
||||
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
|
||||
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
|
||||
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
|
||||
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
|
||||
|
||||
**This price is display-only.** The actual amount charged always comes from the SAME offering
|
||||
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
|
||||
contract for the website to submit an amount. A browser (or a compromised website backend)
|
||||
cannot pay a different price than what's configured on the offering.
|
||||
|
||||
## 2. Submit the registration
|
||||
|
||||
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
|
||||
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
|
||||
|
||||
## 3. Accept the agreement(s)
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/agreements
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
Body: { "agreement_id": "winter-camp-waiver-2026" }
|
||||
```
|
||||
|
||||
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
|
||||
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
|
||||
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
|
||||
|
||||
Which party is recorded as accepting is **server-derived from the registration's own
|
||||
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
|
||||
|
||||
```json
|
||||
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
|
||||
```
|
||||
|
||||
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
|
||||
|
||||
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
|
||||
from step 4). Call this before checkout, always.
|
||||
|
||||
## 4. Create the checkout session
|
||||
|
||||
```
|
||||
POST /api/integrations/registrations/:id/checkout
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
|
||||
```
|
||||
|
||||
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
|
||||
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
|
||||
is no `amount` field — see "Price and availability display" above.
|
||||
|
||||
Success (`201`):
|
||||
|
||||
```json
|
||||
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
|
||||
```
|
||||
|
||||
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
|
||||
`window.location`). In local/staging use with the deterministic test provider (see
|
||||
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
|
||||
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
|
||||
to visit, which is fine for automated testing but means you can't manually click through a
|
||||
"real" checkout page against the test provider.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | `error` | Meaning |
|
||||
|---|---|---|
|
||||
| `401` | `unauthorized` | Missing/wrong integration key. |
|
||||
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
|
||||
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
|
||||
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
|
||||
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
|
||||
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
|
||||
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
|
||||
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
|
||||
|
||||
## 5. Payment confirmation — never trust the redirect
|
||||
|
||||
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
|
||||
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
|
||||
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
|
||||
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
|
||||
enrolled" or grant anything based on the URL/query string alone.**
|
||||
|
||||
## 6. Building a safe success page
|
||||
|
||||
```
|
||||
GET /api/integrations/registrations/:id/status
|
||||
Header: X-RootNode-Integration-Key: <secret>
|
||||
```
|
||||
|
||||
Call this from your OWN backend (never expose the integration key to the browser) when the
|
||||
customer lands on your success page, and poll it every couple of seconds for a short window (the
|
||||
webhook is normally processed within a second or two of the customer completing checkout — see
|
||||
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
|
||||
"status": "confirmed",
|
||||
"payment_status": "paid",
|
||||
"enrollment_activated": false
|
||||
}
|
||||
```
|
||||
|
||||
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
|
||||
of three states on your success page:
|
||||
|
||||
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
|
||||
your email for your RootNode Academy activation link."
|
||||
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
|
||||
payment — this page will update automatically" (keep polling, or tell the customer to check
|
||||
back / check email shortly).
|
||||
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
|
||||
priced offering, a failed payment does NOT release the seat, so the SAME registration can
|
||||
retry checkout (back to step 4) without losing its place.
|
||||
|
||||
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
|
||||
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
|
||||
"expired" webhook processes it — at that point the seat has been released and the customer needs
|
||||
to start a new registration if they still want in.
|
||||
|
||||
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
|
||||
|
||||
## 7. What you must NOT do
|
||||
|
||||
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
|
||||
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
|
||||
Stripe's hosted Checkout page collects it directly.
|
||||
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
|
||||
add one to your own request body it is silently ignored (the price always comes from the
|
||||
offering).
|
||||
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
|
||||
fires automatically on webhook-confirmed conversion (same machinery `docs/
|
||||
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
|
||||
duplicate or conflict with it.
|
||||
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
|
||||
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
|
||||
customer to check their email and stop polling.
|
||||
|
||||
## Accessibility and loading-state notes
|
||||
|
||||
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
|
||||
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
|
||||
return).
|
||||
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
|
||||
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
|
||||
the customer looking at a page that appears frozen while you poll.
|
||||
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
|
||||
map each to a real, specific message rather than one generic "something went wrong."
|
||||
|
||||
## Test-mode flow (what to actually test against)
|
||||
|
||||
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
|
||||
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
|
||||
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
|
||||
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
|
||||
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
|
||||
for a true end-to-end rehearsal before anything goes live with real money.
|
||||
|
||||
## Privacy note
|
||||
|
||||
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
|
||||
already documents for registration (names, emails, phone) — nothing payment-specific is added.
|
||||
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
|
||||
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
|
||||
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
|
||||
provider reference — never card data).
|
||||
Reference in New Issue
Block a user