Let parents register once and book for their children #139

Merged
thatguygriff merged 2 commits from feature/parent-guardian-accounts into main 2026-07-29 19:17:06 +00:00
Owner

Closes #132.

A parent registers once and manages lessons for one or more children, who need no login of their own.

Design decisions

The issue asked for these to be settled first; they were, and the reasoning is in docs/features/parent-guardian-accounts.md.

Question Decision
How is a child represented? (A) a real wp_users row with the student role and no usable login, linked via a new us_guardians table. student_id keeps meaning "a WordPress user" on all seven tables, so booking, credits, enrolments, policies and answers work unchanged.
How do children get in? A checkbox at signup revealing a block per child, plus a Family screen for adding them later.
Who holds payments and credits? Guardian is payer, child is subjectpayer_id on us_payments and us_credits; student_id keeps naming the child.
v1 scope Guardian can also be a student; guardian accepts policies per child (accepted_by); account questions asked per child. Two guardians sharing a child is out, but the schema does not design it out.

The authorisation boundary

POST /bookings and POST /enrollments take an optional student_id, honoured only when GuardianService::canActFor() confirms the caller is that student's guardian — anything else is a 403, resolved before a slot is claimed or a charge raised. Without it any signed-in student could book, and bill, against any user id they cared to send. Same check on cancel and withdraw. Child accounts additionally cannot authenticate at all (ChildLoginGate).

Children first, no parent default

Per feedback during the build: the "Who is this for?" picker lists children first and the account holder last, so the default selection is never the parent. Booking for the wrong child is a correctable inconvenience; silently billing a parent's account for a lesson meant for their kid is the error worth designing out. An account with only itself on the list sees no picker at all.

Schema

New us_guardians; payer_id on us_payments / us_credits; accepted_by on us_policy_acceptances. All three new columns default to 0, read back as "same as student_id", so an existing row keeps its meaning whatever happens. The installer also backfills them, because the balance lookups key on payer_id directly and an indexed WHERE payer_id = 5 would not see a legacy 0. Version: and USC_VERSION bumped to 1.3.0 so dbDelta actually runs.

What else moved

  • Registration — per-child questions and policy acceptances; the whole family is rolled back if any child fails, so a signup never half-works.
  • Billing — the billing-method override, receipts, the Stripe payment step and the daily scan all resolve the payer. A guardian gets one notice covering every child, each line naming whose lesson it is. Family credit balance.
  • Family screen[us_family] / the Family block. Removing a child is refused once they have lessons or enrolments, rather than orphaning that history.
  • Admin — Family column on the students list, Family panel on the student screen, and the credit balance labelled with whose account holds it.
  • Docs: new parent-guardian-accounts.md, cross-links added to the nine related feature docs, changelog entry.

Checks

composer test (762 tests, 2189 assertions), composer lint, composer cs all green. 78 new tests, including the 403 paths on both endpoints and the signup rollback.

Not in this slice

Family discounts, two guardians per child, and creating a child from wp-admin — all noted in the doc, none designed out.

🤖 Generated with Claude Code

Closes #132. A parent registers **once** and manages lessons for one or more children, who need no login of their own. ## Design decisions The issue asked for these to be settled first; they were, and the reasoning is in `docs/features/parent-guardian-accounts.md`. | Question | Decision | |---|---| | How is a child represented? | **(A) a real `wp_users` row** with the student role and no usable login, linked via a new `us_guardians` table. `student_id` keeps meaning "a WordPress user" on all seven tables, so booking, credits, enrolments, policies and answers work unchanged. | | How do children get in? | A **checkbox at signup** revealing a block per child, plus a **Family** screen for adding them later. | | Who holds payments and credits? | **Guardian is payer, child is subject** — `payer_id` on `us_payments` and `us_credits`; `student_id` keeps naming the child. | | v1 scope | Guardian can also be a student; guardian accepts policies per child (`accepted_by`); account questions asked per child. Two guardians sharing a child is **out**, but the schema does not design it out. | ## The authorisation boundary `POST /bookings` and `POST /enrollments` take an optional `student_id`, honoured **only** when `GuardianService::canActFor()` confirms the caller is that student's guardian — anything else is a `403`, resolved before a slot is claimed or a charge raised. Without it any signed-in student could book, and bill, against any user id they cared to send. Same check on cancel and withdraw. Child accounts additionally cannot authenticate at all (`ChildLoginGate`). ## Children first, no parent default Per feedback during the build: the "Who is this for?" picker lists **children first and the account holder last**, so the default selection is never the parent. Booking for the wrong child is a correctable inconvenience; silently billing a parent's account for a lesson meant for their kid is the error worth designing out. An account with only itself on the list sees no picker at all. ## Schema New `us_guardians`; `payer_id` on `us_payments` / `us_credits`; `accepted_by` on `us_policy_acceptances`. All three new columns default to `0`, read back as "same as `student_id`", so **an existing row keeps its meaning whatever happens**. The installer also backfills them, because the balance lookups key on `payer_id` directly and an indexed `WHERE payer_id = 5` would not see a legacy `0`. `Version:` and `USC_VERSION` bumped to **1.3.0** so `dbDelta` actually runs. ## What else moved - **Registration** — per-child questions and policy acceptances; the whole family is rolled back if any child fails, so a signup never half-works. - **Billing** — the billing-method override, receipts, the Stripe payment step and the daily scan all resolve the payer. A guardian gets **one** notice covering every child, each line naming whose lesson it is. Family credit balance. - **Family screen** — `[us_family]` / the **Family** block. Removing a child is refused once they have lessons or enrolments, rather than orphaning that history. - **Admin** — Family column on the students list, Family panel on the student screen, and the credit balance labelled with whose account holds it. - Docs: new `parent-guardian-accounts.md`, cross-links added to the nine related feature docs, changelog entry. ## Checks `composer test` (762 tests, 2189 assertions), `composer lint`, `composer cs` all green. 78 new tests, including the 403 paths on both endpoints and the signup rollback. ## Not in this slice Family discounts, two guardians per child, and creating a child from wp-admin — all noted in the doc, none designed out. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thatguygriff added 2 commits 2026-07-29 19:08:11 +00:00
A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
Keep the guardian service within the PHP the plugin supports
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
8122c158cf
`true` as a return type is PHP 8.2, but the plugin advertises 8.1, so the
family screen's two service calls fataled on the 8.1 test job while every
other job passed. They now return `?\WP_Error` — null on success — which
matches RegistrationGate::validate() and works on 8.1.

PHPStan was analysing against whatever PHP happened to be running (8.3 in
CI, newer locally), so `composer lint` was green on syntax the plugin
promises not to use. It is now pinned to the supported 8.1-8.3 range, which
reproduces this failure at lint time instead of three jobs later.

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff force-pushed feature/parent-guardian-accounts from 4a41ba96fb to 8122c158cf 2026-07-29 19:08:11 +00:00 Compare
thatguygriff merged commit 3c41d1119d into main 2026-07-29 19:17:06 +00:00
thatguygriff deleted branch feature/parent-guardian-accounts 2026-07-29 19:17:06 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#139