recess-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ # Billing, subscriptions, refunds, and cohort registration links
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model, escalation discipline, and input conventions there govern everything below.
4
+
5
+ ## Resolve, then read
6
+
7
+ Search returns the matching people, their family, every family member, and each member's enrollments and cohort registrations:
8
+
9
+ ```bash
10
+ recess --json users search "parent or kid name"
11
+ recess --json enrollments list --user <kid-id>
12
+ ```
13
+
14
+ Read the exact billing state before proposing a write:
15
+
16
+ ```bash
17
+ recess --json subscriptions list --family <family-id> --kid <kid-id>
18
+ recess --json invoices list --subscription <subscription-id>
19
+ recess --json enrollments get-for-subscription --subscription <subscription-id>
20
+ recess --json cohorts search "cohort name"
21
+ ```
22
+
23
+ ## Pausing and resuming collection
24
+
25
+ ```bash
26
+ recess --json billing pause --subscription sub_123 --until 2026-09-01 --confirm
27
+ recess --json billing pause --subscription sub_123 --confirm # indefinite
28
+ recess --json billing resume --subscription sub_123 --confirm
29
+ ```
30
+
31
+ - Without `--until`, the pause is **indefinite** — say so in the approval request; someone must remember to resume it.
32
+ - `--until` must be a future ISO date; the CLI converts it to the Stripe resume timestamp.
33
+
34
+ ### Pause timing semantics (read before choosing `--until`)
35
+
36
+ Verified in source 2026-07-17: `apps/web-server/src/routes/admin/stripe/post.pause-collection.ts` (the endpoint both pause forms hit) and `apps/web-server/src/libs/enrollment/dry-run-stripe-dates.ts` (how subscriptions get their billing anchor).
37
+
38
+ - **Paused invoices are voided.** Both pause forms send Stripe `pause_collection` with `behavior: "void"`: every subscription invoice generated while the pause is active is voided outright — the family is never charged for those cycles, and nothing accrues for later collection. Access is unaffected (the subscription keeps cycling; only collection stops).
39
+ - **Billing ticks are Sunday-anchored.** Weekly course subscriptions are created with `billing_cycle_anchor` = Sunday 00:00 server time (UTC in prod) via `nextSundayEpochTimeS`; monthly products anchor to the 1st of the month. A weekly invoice issued on Sunday S covers the week starting S — so to skip a given session, you void the invoice of the **Sunday that starts that session's week**.
40
+ - **`--until` is the Stripe `resumes_at` timestamp.** A bare ISO date parses as midnight UTC of that date. Every billing tick strictly before it is voided; the first tick at or after it charges normally.
41
+ - **Choosing the date:** identify the Sunday starting the last week to skip (`S_last`) and the following Sunday (`S_next`). Pick an `--until` strictly between them, away from both edges — mid-week is safest. Example: to skip sessions on Thu 7/23 and Thu 7/30, void the Sunday 7/19 and Sunday 7/26 invoices and resume before Sunday 8/2 → `--until 2026-07-29` (anything 7/27 through 8/1 works).
42
+ - **The cohort-wide sibling encodes this SOP server-side:** `cohorts pause-billing --weeks N` computes `resumes_at` = upcoming Sunday + N weeks − 2 hours (`post.cohort-pause-billing.ts`) — skip exactly N Sunday invoices, then resume with a 2-hour safety margin before the next tick so the resume never collides with the billing moment. Mirror that margin thinking when hand-picking `--until`.
43
+ - **DB caveat:** the per-subscription pause writes only to Stripe — it never stamps `Enrollment.pausedAt`/`pauseResumesAt` (the cohort route stamps both). Verify a per-subscription pause via Stripe state (`subscriptions list`), never via the enrollment row.
44
+ - **Known false error on success:** the confirmed write can come back as `api_error` with `details.status: 200` because the route omitted its success body (patched 2026-07-17, false error persists until deployed — see the gotcha in SKILL.md). Treat it as "probably landed" and verify via `subscriptions list` before doing anything else.
45
+
46
+ ## Trials and cancellation
47
+
48
+ ```bash
49
+ recess --json billing extend-trial --subscription sub_123 --trial-end 2026-09-01 --confirm
50
+ recess --json billing cancel-subscription --subscription sub_123 --reason "Family moved off platform" --confirm
51
+ recess --json billing cancel-subscription --subscription sub_123 --restore --confirm
52
+ ```
53
+
54
+ - Default cancellation is **at period end** (the family keeps access until then). `--immediate` removes that grace — treat it as exceptional and call it out explicitly in the approval request, like `who-pays=recess`.
55
+ - `--restore` un-cancels a previously scheduled cancellation. Read the subscription first and confirm it actually has a pending cancellation.
56
+ - Always pass `--reason` on cancellations; it lands in the admin audit trail.
57
+
58
+ ## Refunds and credits
59
+
60
+ ```bash
61
+ recess --json invoices refund --invoice in_123 --line-item il_123 \
62
+ --method refund --full --who-pays guide --reason "Canceled session" --confirm
63
+ ```
64
+
65
+ Three methods, different rules:
66
+
67
+ | `--method` | What happens | Amount rules |
68
+ |---|---|---|
69
+ | `refund` | money back to the payment method (Stripe credit note) | `--full` or `--amount-cents N` |
70
+ | `credit` | credit against the customer's balance (Stripe credit note) | `--full` or `--amount-cents N` |
71
+ | `tokens` | Recess token grant instead of money | requires explicit `--amount-cents`; `--full` is rejected |
72
+
73
+ - `--who-pays` defaults to `guide`. `--who-pays recess` means Recess absorbs the cost — exceptional, name it explicitly in the approval request.
74
+ - Amounts are integer cents. Cross-check any dollar figure the human states before previewing.
75
+ - Target the **line item** (`--line-item`), not the whole invoice; run `invoices list` first to identify it.
76
+ - **Check how the invoice was paid before choosing the method.** `invoices list` exposes the payment composition per invoice — verify it and state it in the approval request:
77
+ - `token_deduction_cents` > 0 → that portion was paid with Recess tokens; give it back as `--method tokens` against `token_refundable_remaining_cents`, never as cash/credit.
78
+ - `applied_balance` < 0 → that portion was covered by existing customer credit; `amount` (actually collected) is less than `subtotal`, so a "full" cash refund would over-refund.
79
+ - Only when `amount` = `subtotal`, `token_deduction_cents` = 0, `applied_balance` = 0, and `paymentIntent.status` = `succeeded` was the invoice fully cash-paid and a `--full` refund/credit clean.
80
+ - Also read `credited_amount`/`post_payment_credited_amount` — a line already credited (memo shows why) must not be credited twice.
81
+
82
+ ## Signing a kid up for a paid class (`enrollments create`)
83
+
84
+ The staff equivalent of a guardian buying a class. **This replaces impersonating the parent and walking their checkout** — same backend path (`initiateStripePayment`), so course discounts, Recess credit deduction, per-kid payment methods, and one-time-vs-recurring bundles all behave exactly as they do for the family.
85
+
86
+ ```bash
87
+ # Always run unconfirmed first — this performs the backend's own dry run.
88
+ recess --json enrollments create --user <kid-id> --cohort <cohort-id>
89
+
90
+ # Then, after explicit human approval of the quoted amount:
91
+ recess --json enrollments create --user <kid-id> --cohort <cohort-id> --confirm
92
+ ```
93
+
94
+ **It spends real money.** The unconfirmed run is not an offline guess — it calls the backend with `dryRun:true` (writes nothing) and returns the *resolved* price, so the approval request must quote `details.billing` verbatim: `effectivePriceCents` (what Stripe bills after any course discount — **not** `listPriceCents`), `interval`, `firstChargeAt`, `chargesImmediately`, and `creditBalanceCents` when non-zero. Never quote a price from the course catalog or from memory; discounts and credits change it.
95
+
96
+ Read the preview before escalating:
97
+
98
+ - **`details.reusedEnrollmentId` is set** → the kid already holds an active enrollment with a free slot for this course. Nothing is charged and no subscription is created; the command just links the registration. Say that plainly — an approver told "we're charging $X" when the answer is $0 has been misled.
99
+ - **`details.warnings` non-empty** → the cohort is at capacity and/or the kid's school-program class slots are exhausted. The write is **rejected** unless you also pass `--force`. Name the specific warning in the escalation; do not reach for `--force` as a reflex.
100
+ - **`chargesImmediately: true`** → the family's card is charged during the confirmed call.
101
+
102
+ ### Deferring the first charge (`--first-charge-at`)
103
+
104
+ ```bash
105
+ recess --json enrollments create --user <kid> --cohort <coh> \
106
+ --first-charge-at 2026-08-02T00:00:00Z --confirm
107
+ ```
108
+
109
+ Recurring bundles only (a one-time bundle always invoices now — the route 400s `FIRST_CHARGE_UNSUPPORTED`). The subscription is created `trialing` until that instant, so **nothing is charged now**, and the recurring cycle re-anchors to it. Weekly Recess subscriptions all bill Sunday 00:00 UTC, so that is almost always the date you want — an arbitrary weekday anchor puts this family permanently out of step with every other weekly sub.
110
+
111
+ Use it for goodwill and remediation: a kid joining mid-week who shouldn't pay for the remainder, or restoring access after a Recess-caused cancellation. Because no invoice is paid, the `invoice.paid` webhook that normally provisions everything never fires — so the route provisions the enrollment and registration **inline**, and the kid has access immediately. `result.mode` is `subscription_deferred` and `provisionedInline` is `true`.
112
+
113
+ With no `--first-charge-at`, the invoice is paid during the call and the webhook does the provisioning; `enrollmentId`/`registrationId` come back `null` and `provisionedInline` is `false`. That is expected, not a failure — verify with `enrollments list --user` a few seconds later rather than treating the nulls as an error.
114
+
115
+ **If it fails with `ENROLLMENT_PROVISION_FAILED`, do not retry.** The Stripe subscription was created; a retry sells a second one. Escalate with the subscription ID from the error message.
116
+
117
+ ## Cohort registration against an enrollment
118
+
119
+ ```bash
120
+ recess --json enrollments register-cohort --enrollment <enrollment-id> --user <kid-id> --cohort <cohort-id> --confirm
121
+ recess --json enrollments unregister-cohort --user <kid-id> --cohort <cohort-id> --confirm
122
+ ```
123
+
124
+ - `register-cohort` links the registration to the named enrollment and sends **no** email (`sendEmail:false`) — it is the quiet administrative link, unlike `registrations approve` which welcomes the family.
125
+ - `unregister-cohort` removes the cohort registration but **leaves the enrollment active** (`cancelEnrollment:false`). Enrollment cancellation is a different workflow: the only CLI path that cancels an enrollment is `registrations deny` (see [`class-ops.md`](class-ops.md)). Never substitute one for the other.
126
+
127
+ ### Switching a kid's cohort (the move play)
128
+
129
+ Composing the two commands moves a kid (or a whole roster) to another cohort **without ever breaking the enrollment link or touching billing**. Proven 2026-07-17 moving 10 kids across two source cohorts.
130
+
131
+ 1. **Read both cohorts first** (`cohorts get` each): confirm the destination belongs to the **same course** as the enrollments; compare destination `capacity` against current registered count + incoming headcount; collect each kid's `userId` + `enrollmentId` from the source roster; note schedule/timezone/guide differences for the escalation.
132
+ 2. **Order is register-first, unregister-second, per kid** — never the reverse. A register that fails (capacity, state) then leaves the kid safely registered in the old cohort; the reverse order would strand them registered nowhere.
133
+ ```bash
134
+ recess --json enrollments register-cohort --enrollment <enr> --user <kid> --cohort <new> --confirm
135
+ recess --json enrollments unregister-cohort --user <kid> --cohort <old> --confirm
136
+ ```
137
+ 3. **Batch shape:** preview everything → one escalation → run ALL registers → then only the unregisters whose register succeeded.
138
+ 4. **Capacity is enforced server-side even on this admin path** (400 `RA_REG_NOT_ALLOWED` "This cohort is at capacity"); capacity editing is web-admin-only, so check it in step 1 or expect safe mid-batch failures.
139
+ 5. **Registrations without an Enrollment** (guide's own kid, ops accounts) can't be moved this way — `register-cohort` requires `--enrollment`. List them for the human.
140
+ 6. **Verify:** both rosters via `cohorts get`, and each moved kid's enrollment still ACTIVE on the same `stripeSubscriptionId` (`enrollments list --user`).
141
+ 7. **Call out in the escalation:** zero family notification (both commands are quiet — offer a `cohorts email` to the destination cohort afterward for the schedule change), billing untouched, and the emptied source cohorts stay ACTIVE (`cohorts end` is a separate decision).
142
+ 8. **⚠️ Never run `cohorts end --cancel-subscriptions` on the source cohort afterward.** A move reuses the *same* enrollment and Stripe subscription, and leaves a `CANCELED_REFUNDED` registration row behind in the source cohort. `cohorts end` selects its victims by *any* registration in that cohort regardless of status, so it cancels the subscription that is now paying for the **destination** cohort — and about a week later, when Stripe fires `customer.subscription.deleted`, every registration on that enrollment is swept, including the new one. **This happened on 2026-07-19** and silently unenrolled 9 of 10 just-migrated kids; the only enrollments still ACTIVE on the old cohorts were the ones that had just been moved, so the blast radius was exactly the migrated roster. End the old cohort **without** the flag. Full mechanics: [`web-server.md`](https://github.com/tryrecess/monolith/blob/staging/docs/codebase/web-server.md) § "cohorts end --cancel-subscriptions after a cohort migration".
143
+
144
+ ## Verify
145
+
146
+ After confirmed writes, re-read: `subscriptions list` for pause/trial/cancel state, `invoices list` for the credit note, `enrollments list` / `cohorts get` for registration changes. Report the final API responses.
@@ -0,0 +1,53 @@
1
+ # Class-cancellation credits — the "Please credit these students accordingly" workflow
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model, escalation discipline, and input conventions there govern everything below. Pause/refund mechanics live in [`billing.md`](billing.md); read its "Pause timing semantics" and refund payment-composition checklist before running this.
4
+
5
+ ## Trigger and why this playbook exists
6
+
7
+ When a guide cancels a class session (`events cancel`, or the same route from the web), the backend (`apps/web-server/src/routes/admin/events/post.cancel-event.ts`) fires the family-facing fan-out AND posts to Slack `#cohort-cancellations`:
8
+
9
+ > 🚫 Class Canceled … *Registered students:* … _Please credit these students accordingly._
10
+
11
+ It also stamps a `UserNote` ("Credit owed: {course} on {date} was canceled…") on each kid registered **at cancel time**. That is where automation stops: **nothing refunds the families.** The crediting is manual, per-kid, and error-prone — this playbook is that step, done agentically. Forensics from the 2026-07-16 "Honey Squad - Steam Games" cancellation (verified via Apitally request logs + live invoice reads) showed the manual process lagging 2 days, silently under-refunding a mixed-composition invoice, and working from a stale roster. Every rule below traces to an observed failure.
12
+
13
+ ## The procedure
14
+
15
+ **1. Resolve the canceled event.**
16
+ ```bash
17
+ recess --json cohorts search "<cohort name from the Slack message>"
18
+ recess --json cohorts get <cohort-id>
19
+ ```
20
+ Find the CANCELED event matching the stated date (cohort-local time — "1:30 PM PDT Thursday July 16" = `2026-07-16T20:30Z` for a `America/Los_Angeles` cohort). Note `course.eventsPerWeek` and the bundle's weekly price.
21
+
22
+ **2. Build the roster LIVE — never from the Slack message.** The Slack roster is a cancel-time snapshot of `status: REGISTERED` registrations and has been observed to **under-report** (2026-07-16: a kid registered since 2025 was invoiced for the canceled week yet absent from the Slack list). Work from the cohort's current REGISTERED registrations, cross-checked both directions:
23
+ - In Slack but no longer registered / enrollment gone → skip, note why in the escalation.
24
+ - Registered + invoiced for the canceled week but absent from Slack → include.
25
+ - Registration with **no Enrollment** (guide's own kid, ops/test accounts) → nothing to refund; list as "no billing".
26
+
27
+ **3. For each kid, find the invoice that covers the canceled session.**
28
+ ```bash
29
+ recess --json enrollments list --user <kid-id> # → the enrollment registered to THIS cohort → stripeSubscriptionId
30
+ recess --json invoices list --subscription <sub-id>
31
+ ```
32
+ Weekly subscriptions bill on Sunday-00:00-UTC anchors ([`billing.md`](billing.md)): the invoice created on the **Sunday starting the session's week** is the one owed back (session Thu 7/16 → the Sunday 7/12 invoice). No such invoice (pause, trial, joined mid-week) → skip, say so. Match invoice IDs **in full** — two distinct same-week invoices have shared their first 18 characters (`in_1TsAobBBeAjEgjls…` twice).
33
+
34
+ **New-signup trap:** signups create an immediate backdated invoice + a $0 "Trial period" invoice, and the sub sits `trialing` until a Sunday anchor (see the SKILL.md 2026-07-17 signup-billing gotcha). The immediate invoice pays for the kid's FIRST class week, not necessarily the session's week; if `trial_end` (from `subscriptions list`) is at/after the Sunday ending the session's week, the family was never charged for that session → skip, and don't claw back the immediate invoice (it maps to an earlier session).
35
+
36
+ **4. Compute what is owed.** The canceled session's share of that week's invoice line. With `eventsPerWeek: 1` that is the full line (`--full` / the full weekly price). Multi-session weeks: the per-session share — confirm the split with the human rather than guessing.
37
+
38
+ **5. Choose the instrument(s) from the invoice's payment composition** (checklist in [`billing.md`](billing.md)):
39
+
40
+ | Composition | Action |
41
+ |---|---|
42
+ | Fully cash-paid (`token_deduction_cents` 0) | `invoices refund --method credit --full` (or `--amount-cents` for a per-session share) |
43
+ | Fully token-paid | `--method tokens --amount-cents <token portion>` |
44
+ | **Mixed** (tokens + cash) | **BOTH commands**: `tokens` for `token_deduction_cents`, `credit` for the cash remainder (`amount`). Observed live 2026-07-17: ops refunded a $4 token portion and missed the $11 cash portion of a $15 line |
45
+ | Already credited (`credited_amount` > 0 or `token_refunded_cents` > 0) | **skip** — someone processed it first. This workflow runs days late and sometimes concurrently with a human; always re-read before previewing |
46
+
47
+ **6. Flags.** `--who-pays guide` (the default) for guide-initiated cancellations. Reason: `"Guide cancellation - <cohort> <session date>"` (the observed convention is bare "Guide cancellation"; include the session date so repeat cancellations stay distinguishable).
48
+
49
+ **7. Batch escalate.** One approval request: a table of kid → invoice (full ID) → line item → method → amount → reason, plus every skip and why. Then `--confirm` each unchanged command, then verify by re-reading each invoice (`credited_amount` / `token_refunded_cents`) and report final state.
50
+
51
+ ## When the CLI itself cancels the class
52
+
53
+ `events cancel` triggers this same Slack message and the credit-owed notes ([`class-ops.md`](class-ops.md)). After a confirmed `events cancel`, offer to run this playbook immediately — same-session processing beats the observed 2-day lag and roster drift.
@@ -0,0 +1,41 @@
1
+ # Non-flexible course time shifts (`events reschedule` blocked)
2
+
3
+ Companion to `reference/class-ops.md` § Reschedules and one-off sessions.
4
+
5
+ ## Gate
6
+
7
+ `events reschedule` requires `course.allowFlexibleScheduling === true` on `cohorts get`.
8
+
9
+ When false, confirmed write returns API 400:
10
+
11
+ > This course does not allow editing individual event times. Enable flexible scheduling on the course first.
12
+
13
+ CLI cannot toggle the flag — web admin course settings only. Do not stop at "enable flexible scheduling" if staff needs the move now; use the workaround below.
14
+
15
+ ## Worked example (2026-07-23)
16
+
17
+ - Cohort: Space Technology & Rocket Launches (`1be248c5-acb9-472e-91d7-7d1dc9f37dd0`)
18
+ - Course: Current Events in Space Tech & Rockets — `allowFlexibleScheduling: false`
19
+ - Ask: move today up 10 minutes (Starship)
20
+ - Usual slot: Thu 15:45 America/Edmonton → UTC `21:45`
21
+ - Target: 15:35 Edmonton
22
+ - Prior week evidence: ENDED pair on 2026-07-16 at both `21:35` and `21:45` UTC — ops already used one-off add, not true reschedule
23
+
24
+ ## Workaround (batch one approval)
25
+
26
+ ```bash
27
+ # 1) Preview both (no --confirm)
28
+ recess --json events add --cohort <cohort-id> \
29
+ --starts-at YYYY-MM-DDTHH:MM --timezone <cohort-tz> --length-mins 60
30
+ recess --json events set-status <original-event-id> --status CANCELED
31
+
32
+ # 2) After human OK, same commands with --confirm
33
+ # 3) Verify
34
+ recess --json cohorts get <cohort-id> --events-tab ACTIVE
35
+ ```
36
+
37
+ Rules:
38
+
39
+ - Use `set-status CANCELED` (silent). Never `events cancel` unless staff asked to notify families.
40
+ - Approval text must say: new one-off + silent cancel of original, no parent blast.
41
+ - Prefer this path over asking staff to flip flexible scheduling mid-incident when history shows prior one-offs.
@@ -0,0 +1,89 @@
1
+ # Class ops (attendance, cancellations, schedule changes, cohort lifecycle, registration approvals)
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model, escalation discipline, and input conventions there govern everything below.
4
+
5
+ Cohorts are recurring class groups; each session is an event. Staff instructions arrive in plain language ("cancel Thursday's KSP and let families know", "take attendance for yesterday's 2pm session", "approve the pending signups"). Resolve them to IDs before writing.
6
+
7
+ ## Resolve the cohort and event first
8
+
9
+ ```bash
10
+ recess --json cohorts search "Kids Startup Program"
11
+ recess --json cohorts get <cohort-id>
12
+ recess --json events get <event-id>
13
+ ```
14
+
15
+ `cohorts get` returns the cohort's events (IDs, dates, statuses; filter with `--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED`), its registrations including PENDING_APPROVAL ones with their registrationIds, and the derived billing-pause state. `events get` returns the session's roster with each kid's current attendance mark and per-cohort attendance counts. Match a spoken time ("Thursday's class", "the 2pm session") against event dates in the cohort's timezone; if more than one event fits, ask the human which one.
16
+
17
+ ## Attendance
18
+
19
+ ```bash
20
+ recess --json events take-attendance <event-id> \
21
+ --attended <kid-id,kid-id> --absent <kid-id> --excused <kid-id> --confirm
22
+ ```
23
+
24
+ - Run `events get` first and account for every REGISTERED kid: each goes in exactly one of `--attended`, `--absent`, or `--excused`. The CLI rejects overlapping lists and requires at least one list.
25
+ - The attendance timestamp is set to now automatically; there is no back-dating flag.
26
+ - Non-excused absentees can trigger automatic "we missed you" emails to their parents (when the cohort has absence emails on), and two consecutive absences alert staff in Slack. Name this in the approval request — mis-marking a kid absent emails a real family.
27
+ - "Everyone came except Matthew (excused)" → every other registered kid in `--attended`, Matthew in `--excused`.
28
+
29
+ ## Cancel vs silent status change
30
+
31
+ Two different operations — choose deliberately:
32
+
33
+ ```bash
34
+ recess --json events cancel <event-id> --reason "Guide is sick" --confirm
35
+ recess --json events set-status <event-id> --status CANCELED --confirm
36
+ ```
37
+
38
+ - "Cancel the class and tell families" → `events cancel`. It notifies kid and parent chat channels, emails every registered parent, files a credit-owed note per student, and posts to Slack. There is no quiet variant of this command; the reason text is shown to families.
39
+ - Canceling does NOT refund anyone — the Slack message ends with "Please credit these students accordingly" and that step is on us. After a confirmed `events cancel`, offer to run [`cancellation-credits.md`](cancellation-credits.md) immediately.
40
+ - Administrative bookkeeping (event created by mistake, duplicate, already communicated) → `events set-status --status CANCELED`, which sends nothing.
41
+ - Reactivate or publish a session → `events set-status --status ACTIVE`; mark it finished → `--status ENDED`.
42
+ - If the instruction is ambiguous about notifying, ask; never guess an outward blast.
43
+
44
+ ## Reschedules and one-off sessions
45
+
46
+ ```bash
47
+ recess --json events reschedule --cohort <cohort-id> --event <event-id> \
48
+ --starts-at 2026-07-23T15:00 --timezone America/Los_Angeles --confirm
49
+ recess --json events add --cohort <cohort-id> --starts-at 2026-07-23T15:00 \
50
+ --timezone America/Los_Angeles --length-mins 60 --confirm
51
+ ```
52
+
53
+ - Always move a session with `events reschedule` — never by editing the event's status or date directly. The reschedule path leaves a tombstone so schedule regeneration doesn't re-create the old slot.
54
+ - Times are zoneless cohort-local: "move to 3pm" means `15:00` in the cohort's timezone; a timezone offset in `--starts-at` is rejected. Pass `--timezone` using the value from `cohorts get` when unsure.
55
+ - `--length-mins` overrides the session length; omit it to keep the cohort default.
56
+ - Only flexible-scheduling courses and ACTIVE events can be rescheduled; a 409 means the target slot already has an event.
57
+
58
+ ## Cohort lifecycle and email
59
+
60
+ ```bash
61
+ recess --json cohorts end <cohort-id> --confirm
62
+ recess --json cohorts pause-billing <cohort-id> --weeks 2 --confirm
63
+ recess --json cohorts resume-billing <cohort-id> --confirm
64
+ recess --json cohorts email <cohort-id> --target ALL_PARENTS --content "..." --confirm
65
+ ```
66
+
67
+ - `cohorts end` (no flag) archives the cohort's channels and future events and leaves subscriptions untouched. `--cancel-subscriptions` sets EVERY active enrollment's Stripe subscription to cancel at period end — treat the flag as exceptional and call it out explicitly in the approval request, like `who-pays=recess`.
68
+ - `pause-billing` pauses Stripe collection for every subscription in the cohort for 1–6 weeks (invoices void while paused). The server computes the window itself: `resumes_at` = upcoming Sunday 00:00 (server time, UTC in prod) + N weeks − 2 hours, because weekly subscriptions bill on Sunday-midnight anchors — so `--weeks N` skips exactly N Sunday invoices and resumes just before the next one (`post.cohort-pause-billing.ts`; full timing semantics in [`billing.md`](billing.md)). It also stamps `Enrollment.pausedAt`/`pauseResumesAt`, which is where `cohorts get`'s derived billing-pause state comes from. Enrollments already paused (`behavior: "void"`) or with `expireAtPeriodEnd` are skipped. Both directions are Stripe writes affecting real payments. `resume-billing` takes no `--weeks` and clears the pause plus the enrollment stamps.
69
+ - `cohorts email` sends a real outward email blast. Before requesting approval, run `cohorts parent-emails <cohort-id>` and include the recipient count in the escalation. `ALL_PARENTS_GUIDES` adds guides, owners, and support. Content starting with a dash run needs the `--content="…"` form (see the flag parser traps in SKILL.md).
70
+
71
+ ## Registration approvals
72
+
73
+ `cohorts get` lists PENDING_APPROVAL registrations with their registrationIds.
74
+
75
+ ```bash
76
+ recess --json registrations approve --registration <registration-id> --confirm
77
+ recess --json registrations deny --cohort <cohort-id> --user <kid-id> --confirm
78
+ ```
79
+
80
+ - Approve flips the registration to REGISTERED and sends the family a welcome email.
81
+ - Deny removes the registration AND cancels the enrollment — that is what the web admin's Deny button does. Never substitute the enrollment-preserving `enrollments unregister-cohort` for a denial (or vice versa).
82
+
83
+ ## Batch and verify
84
+
85
+ Same discipline as payout ops: collect all previews without `--confirm`, present one batched approval request that names every side effect (family notifications, absence emails, Stripe changes, welcome emails), then rerun unchanged with `--confirm` and re-read (`events get` / `cohorts get`) to verify the new state.
86
+
87
+ ## Out of scope
88
+
89
+ Cohort creation, start, full schedule edit/RRULE regeneration, generate-events, and guides management stay in the web admin cohort pages. If asked, direct the human there.
@@ -0,0 +1,28 @@
1
+ # MAP Growth report uploads
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model and escalation discipline there govern everything below.
4
+
5
+ Resolve the kid with `users search`, then preview the exact local PDF before uploading it:
6
+
7
+ ```bash
8
+ recess --json students upload-map-scores --student <kid-id> --file /path/to/map-report.pdf
9
+ ```
10
+
11
+ The command accepts one non-empty PDF up to 15 MB (validated locally — wrong extension, missing file, empty file, and oversize all fail before any network call). Its no-write preview includes the resolved absolute path, filename, byte count, content type, and SHA-256; show all of that to the human so approval identifies the exact student and file. Only after explicit approval, rerun the unchanged command with `--confirm`.
12
+
13
+ ## Two valid outcomes
14
+
15
+ The confirmed upload runs the existing tutor-dashboard AI extraction pipeline, so it can take **materially longer** than other admin writes — do not treat a slow response as a hang. The response distinguishes:
16
+
17
+ - `kind: "scores"` — the route inserted measured MAP rows; report `inserted` and `scoreIds`.
18
+ - `kind: "learning_statements"` — the PDF was a narrative NWEA Learning Statements/Continuum report with no measured RIT rows; **zero inserted scores is expected and is a success**, and `learningStatementFiles` names the Mesa files created for the tutor.
19
+
20
+ ## Verify
21
+
22
+ For a score upload, verify the resulting rows through the allowed read-only escape hatch:
23
+
24
+ ```bash
25
+ recess --json request get /tutor/students/<kid-id>/map-test-scores
26
+ ```
27
+
28
+ Do not call the multipart route by hand, and do not treat a narrative report's zero inserted rows as failure.
@@ -0,0 +1,79 @@
1
+ # Onboarding (family stage, account state, attestations, parent intake)
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model, escalation discipline, and input conventions there govern everything below.
4
+
5
+ Onboarding tracks a family from provisioned kid accounts to a fully set-up, cohort-ready household. Each family carries an `onboardingStage`, an `accountState`, a four-condition attestation checklist, and a parent intake session that structured setup data is collected into. All nine commands are keyed on the **familyId** (not a kid or user id) — resolve it with `users search` first.
6
+
7
+ ## Resolve the family and read its state
8
+
9
+ ```bash
10
+ recess --json users search "parent or kid name"
11
+ recess --json onboarding status <family-id>
12
+ ```
13
+
14
+ `onboarding status` is a read (no confirmation gate). It returns the current `onboardingStage`, `accountState`, `stageUpdatedAt`, the attestation `checklist` (each of `app_downloaded` / `tutor_met` / `goals_loaded` / `ma_diagnostic` with who attested and when), and per-kid setup signals (`activeGoalCount`, `appOpened`). Read it before any onboarding write so approvals name the exact starting state.
15
+
16
+ ```bash
17
+ recess --json onboarding kids [--time-period-days N] [--cohort <id>] [--limit N] \
18
+ [--stage-filter all|scheduled|oriented|course|converted|lost]
19
+ ```
20
+
21
+ `onboarding kids` is the legacy funnel list — recently-onboarding kids with their parent, engagement counts, membership/enrollment status, and highest funnel stage. It is a read; the flags map one-to-one onto the endpoint's query (`timePeriodDays`, `cohortId`, `limit`, `stageFilter`). The `stageFilter` funnel values (`scheduled`/`oriented`/`course`/`converted`/`lost`) are the legacy per-kid funnel, distinct from the family `onboardingStage` enum below.
22
+
23
+ ## The end-to-end order
24
+
25
+ A family moves through setup roughly in this order; the attestation checklist mirrors it:
26
+
27
+ 1. **App downloaded** on the kid's device → `attest --condition app_downloaded`.
28
+ 2. **Tutor + guide met** the family (intro call / orientation) → `attest --condition tutor_met`, and the parent-intake session is where that call's notes get captured (`intake-session`, then `intake-session-create` if absent → `set-intake` / `extract`).
29
+ 3. **Goals loaded** for each kid → `attest --condition goals_loaded`.
30
+ 4. **Math Academy diagnostic** taken → `attest --condition ma_diagnostic`.
31
+
32
+ The family `onboardingStage` is the coarse gate that advances alongside this:
33
+ `LEGACY → PROVISIONED → PARENT_CONFIRMED → CLEARED_FOR_COHORT → COMPLETE`. `CLEARED_FOR_COHORT` is the one with a live capability effect — it unlocks cohort registration for school-partner families (`apps/web-server/src/libs/capabilities.ts`). `PARENT_CONFIRMED` reflects the parent confirming setup in-app.
34
+
35
+ ## Parent intake session (read/create → fill → confirm)
36
+
37
+ ```bash
38
+ recess --json onboarding intake-session <family-id>
39
+ recess --json onboarding intake-session-create <family-id> [--confirm]
40
+ recess --json onboarding set-intake <family-id> --session <session-id> --data '<json>' [--expected-updated-at <iso>] [--confirm]
41
+ recess --json onboarding extract <family-id> --session <session-id> \
42
+ (--transcript-file <path> | --granola <ref>) [--confirm]
43
+ ```
44
+
45
+ - `intake-session` is a **read-only GET**: it returns the family's existing IN_PROGRESS session, or a clean "none yet" result when the route returns 404. It never creates a session (`get.family-intake-session.ts`).
46
+ - `intake-session-create` is the explicit **confirmed write**: its POST returns the current IN_PROGRESS session or creates a fresh one when none exists (`post.family-intake-session.ts`). Run it only when the read reports no session, then use its `sessionId` for the fill commands.
47
+ - `set-intake` **writes** the structured `collectedData` object for the session (PUT). `--data` is raw JSON — this is an agent-driven path (`--json`); a human rarely hand-writes the object. Invalid JSON fails locally as `invalid_arguments` before any network call; the preview shows the parsed object and does **zero** network I/O. The optimistic-concurrency token resolves only on `--confirm`, in two modes: pass `--expected-updated-at <iso>` (the `updatedAt` from an `intake-session` read) for **strict CAS** — a stale token 409s (surfaced as a non-zero-exit error) if a parent's confirm-flow autosave bumped the session since; **omit it** and the CLI fetches the current token at confirm time and prints a stderr warning that edits made since your preview are not protected. Use strict mode whenever you previewed against a specific read.
48
+ - `extract` **writes** by running the intake transcript through Claude and merging the result into the session. Pass **exactly one** source — `--transcript-file <path>` (read locally into `transcript`) or `--granola <ref>` — both or neither fails as `invalid_arguments` before any network call. It is LLM-bound and can take ~30s; do not treat a slow response as a hang. A `503` means Granola is not configured server-side.
49
+
50
+ The relationship: `intake-session` (read) → `intake-session-create` when absent → `set-intake` or `extract` (populate it) → the parent confirms setup in-app (which moves the stage toward `PARENT_CONFIRMED`).
51
+
52
+ ## Stage and account-state writes
53
+
54
+ ```bash
55
+ recess --json onboarding set-stage <family-id> \
56
+ --stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
57
+ recess --json onboarding set-account-state <family-id> \
58
+ --state ACTIVE|PENDING_PAYMENT|PAUSED|BOOTED [--note TEXT] [--confirm]
59
+ ```
60
+
61
+ - `set-stage` reads the current stage first (one GET before the confirmation gate) so the preview names the transition (`target.from`) and warns when the move goes **backward** — a lower stage re-locks progress the family already passed, and the action string is prefixed `MOVE BACKWARD — re-locks progress`. Confirm backward moves deliberately; `CLEARED_FOR_COHORT` gating cohort registration means dropping below it removes that access.
62
+ - `set-account-state` sets the family's billing/access posture. `PAUSED` and `BOOTED` **lock the family out of paid capabilities** — the preview spells this out; call it out in the approval request. `--note` is analytics-only (recorded, not shown to the family).
63
+
64
+ ## Attestations
65
+
66
+ ```bash
67
+ recess --json onboarding attest <family-id> \
68
+ --condition app_downloaded|tutor_met|goals_loaded|ma_diagnostic [--revoke] [--note TEXT] [--confirm]
69
+ ```
70
+
71
+ Marks (or, with `--revoke`, clears) one checklist condition. The preview action reads `attest <condition>` or `REVOKE attestation <condition>`. `--note` is passed through. Read `onboarding status` first to see which conditions are already attested and by whom.
72
+
73
+ ## Batch and verify
74
+
75
+ Same discipline as the other domains: collect every planned write's preview without `--confirm`, present one batched approval that names the side effects (stage backward moves, PAUSED/BOOTED lockout, the ~30s LLM extract), rerun unchanged with `--confirm`, then re-read `onboarding status` to confirm the new stage / checklist / account state.
76
+
77
+ ## Out of scope
78
+
79
+ There is no `mcp serve` mode and no server-side PAT path for these endpoints yet — the CLI drives them over the normal admin cookie session only. Kid provisioning, device pairing, and the in-app parent-confirmation UI stay in the product surfaces that own them.
@@ -0,0 +1,74 @@
1
+ # Guide payout ops (biweekly changes)
2
+
3
+ Read [`../SKILL.md`](../SKILL.md) first — the safety model, escalation discipline, and input conventions there govern everything below.
4
+
5
+ Guides and program partners are paid through payout invoices: a biweekly pay run spawns one invoice per payout recipient account, most line items are generated automatically from Stripe payments or taught sessions, and the operations team applies a short instruction of manual changes each cycle ("CHANGES TO MAKE EVERY 2WKS: Add for X … Delete Y …"). Your job is to translate that instruction into exact CLI writes.
6
+
7
+ ## 1. Identify the pay cycle
8
+
9
+ ```bash
10
+ recess --json payout payruns list --status IN_REVIEW,DRAFT,UPCOMING
11
+ ```
12
+
13
+ Pay runs are named like "Biweekly: Jun 28, 2026 - Jul 12, 2026". Valid `--status` values: `UPCOMING, DRAFT, IN_REVIEW, APPROVED, PAID, CANCELED`. A recurring-changes instruction targets the run currently being prepared (usually IN_REVIEW or DRAFT) unless it names dates. If more than one run is plausible, ask the human which cycle they mean.
14
+
15
+ ## 2. Resolve each recipient
16
+
17
+ ```bash
18
+ recess --json payout recipients list --search "Tom Bickmore"
19
+ ```
20
+
21
+ - Instructions name people or programs. A person maps to an account like "Tom B.'s Payout Account"; a program ("Guild of Imagination") maps to a program account with no linked user.
22
+ - A guide can have TWO accounts (a TAKE_RATE account and an HOURLY account), which means two invoices in the same run. When a search returns more than one account, inspect both invoices' line items to determine which one the instruction targets, and state which account you chose in the approval request.
23
+ - Zero matches: stop and ask the human; never guess a similar name.
24
+
25
+ ## 3. Find and inspect the invoice before writing
26
+
27
+ ```bash
28
+ recess --json payout invoices list --payrun <payrun-id> --recipient <account-id>
29
+ recess --json payout invoices get <invoice-id>
30
+ ```
31
+
32
+ `payout invoices list` requires at least one filter (`--payrun`, `--recipient`, `--user`, or `--status`). Check before every write:
33
+
34
+ - The invoice status permits the operation: adding items requires DRAFT or IN_REVIEW; editing allows DRAFT, IN_REVIEW, or OPEN; deleting items requires DRAFT or IN_REVIEW.
35
+ - `mayHaveDuplicates: true` means a Stripe payment already appears on another invoice — flag it to the human instead of ignoring it.
36
+
37
+ ## 4. Apply the changes
38
+
39
+ ```bash
40
+ recess --json payout items add --invoice <id> --amount-cents 28800 \
41
+ --description "Recess+ for 8 kids at $18 2wks 2x$144" --confirm
42
+ recess --json payout items edit <item-id> --amount-cents 4000 --confirm
43
+ recess --json payout items delete <item-id> --confirm
44
+ recess --json payout invoices set-status <invoice-id> --status CANCELED --confirm
45
+ ```
46
+
47
+ Conventions that make the changes correct:
48
+
49
+ - **Amounts are integer cents.** Parse dollar expressions yourself and cross-check any stated total: "2x$144 = $288" is one line item of 28800 cents unless the instruction clearly means separate items. If your arithmetic disagrees with a stated "= $X", surface the discrepancy instead of picking one.
50
+ - **Descriptions**: reuse the instruction's wording, minus trailing arithmetic ("… 2x$53.20 = $106.40" → "… 2x$53.20"). The same items recur every cycle, and stable wording lets the next cycle's operator match them.
51
+ - **Item date**: when the instruction gives no date, omit `--date`; the CLI defaults to the penultimate day of the invoice cycle (endDate minus one day) and shows the computed date in the preview (this default requires one read of the invoice before the confirmation gate — expected). Confirm it looks right. An explicit `--date` takes bare `YYYY-MM-DD` (anchored to UTC noon) or a full ISO timestamp.
52
+ - **Edits only touch custom items.** `payout items edit` sets net = total (custom line items carry no platform fee); auto-generated Stripe/session items have different net semantics — don't edit them, flag them to the human.
53
+ - **Recurring instructions**: "EVERY 2WKS" items are re-added fresh each cycle; there is no copy-forward mechanism. When an instruction is terse, read the previous run's invoice for the same recipient and mirror its amounts and descriptions.
54
+ - **"Delete <person>"** means take the person off this cycle's payroll: cancel their invoice with `set-status --status CANCELED` (for example, a guide who moved to Deel). **"Delete <line item>"** means soft-delete one item with `payout items delete`. "Delete the first one" is ambiguous — list the invoice's items in order and confirm the target with the human before deleting.
55
+
56
+ ## 5. Status changes carry side effects
57
+
58
+ `payout invoices set-status` previews spell these out; repeat them in the approval request:
59
+
60
+ - `IN_REVIEW` moves the invoice into review.
61
+ - `OPEN` finalizes: adds the invoice total to the recipient's running account balance.
62
+ - `PAID` marks paid and deducts the total from the account balance.
63
+ - `CANCELED` cancels the invoice and reverses balance items carried on it.
64
+ - The guide email on `IN_REVIEW`/`OPEN` fires **only with `--send-email`** (default: no email). Follow the instruction: "finalize and notify" needs the flag; a quiet finalize omits it.
65
+ - A `PAID` or `CANCELED` invoice is terminal — the server rejects any further status change (400).
66
+ - Never finalize, mark paid, or cancel without an explicit instruction to do so.
67
+
68
+ ## 6. Batch the escalation, verify after
69
+
70
+ For a multi-recipient instruction, first run every read, then run every write WITHOUT `--confirm` to collect previews, and present the complete per-recipient change plan (every add, edit, delete, and cancel with its preview) as ONE approval request. After approval, run each command unchanged with `--confirm`, then re-read each touched invoice with `payout invoices get` and verify the new `totalAmountCents` matches your arithmetic.
71
+
72
+ ## Out of scope
73
+
74
+ Money movement and bulk pay-run lifecycle are deliberately not in the CLI: initiating Mercury payouts, advancing whole pay runs, and generating or regenerating invoices happen in the web admin at `/admin/payout`. If asked, direct the human there.