Files
unsupervised-scheduler/docs/features/parent-guardian-accounts.md
T
thatguygriffandClaude Opus 5 cb347ffca0
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
Five items from the latest demo pass:

- A policy's title can be edited from the Policies screen. Only the title
  moves; the slug is what the gates resolve policies by, so a rename can
  never detach a policy from acceptances already recorded against it.
- Signup is one page again. The studio's registration questions move from
  a second step behind "Next" onto the main form, in an "About you" panel
  above the students being added, and that panel also asks an adult
  student for their birth year (the same us_birth_year meta a child's
  uses). register.js disables and hides the whole panel for a pure
  guardian, since the questions describe a student.
- The password is re-scored on submit, not only as it is typed. zxcvbn's
  dictionary arrives after page load, so a password typed straight away
  was never scored at all and the first the student heard of it was the
  server rejecting the whole form.
- Group-class sessions appear alongside lessons wherever upcoming lessons
  are listed: the [us_scheduler] panel (students and instructors) and the
  admin student detail page. GroupClass\SessionSchedule derives them from
  Offering::sessionWindows(), the same derivation the billing scan uses.
  They carry kind = 'group_class' and no Cancel action - a session is one
  date in a term, not a booked slot.
- Deleting a user releases what the account was holding: each upcoming
  lesson is cancelled, its slot freed for rebooking, its pending payment
  voided, and active class enrolments cancelled. Past lessons and paid
  history are left alone.

Tests: composer test (851), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 11:45:04 -03:00

409 lines
21 KiB
Markdown

# Feature: Parent/Guardian Accounts
## Overview
A parent or guardian registers **once** and manages lessons for **one or more
children**, without each child needing their own login. The guardian signs in,
picks which child a booking is for, and pays for all of them from one account.
A guardian may also be a student in their own right — they appear in their own
"who is this for?" selector alongside their children, so a parent taking lessons
next to their kids needs only the one account.
## Vocabulary: "child" in the code, "student" in the UI
The interface says **student** and **profile**; the code says **child** and
**family**. This is deliberate, not drift. Every identifier below — the
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
request parameters, the `child_name` form fields, the `us-scheduler/family`
block name and the `[us_family]` shortcode — is a stable contract with the
database, saved post content and existing installs, so renaming them would break
sites for no user-visible gain. Only the strings a person reads were changed.
When adding to this feature, keep the split: internal names follow the
data model, translatable strings follow the interface.
## Core Decision: children are accountless WordPress users
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
`us_credits`, `us_group_enrollments`, `us_question_answers`,
`us_policy_acceptances`, `us_group_access`) is a `wp_users` id, and booking,
billing, credits, policies and registration answers all resolve it directly.
Rather than change what `student_id` means, **a child is a real `wp_users` row**
with the `us_student` role, created without a usable login:
- no password (`wp_generate_password()` is used and discarded — nothing is ever
emailed, so it cannot be guessed into a session),
- no real email address; a child gets a placeholder login on the RFC 2606
reserved `.invalid` TLD (`us-child-<random>@child.invalid`, see
`GuardianService::childEmail()`) — a well-formed address that can never
resolve, so nothing about a child's account can be emailed somewhere real,
- the `us_child` user meta flag set to `1`, which
`Guardian\ChildLoginGate` uses to block authentication outright.
Consequences:
- `us_lessons`, `us_group_enrollments`, `us_question_answers` and
`us_group_access` are **unchanged** — a child books like any other student.
- A child can be promoted to their own login later by setting a password and a
real email and clearing `us_child`; no data migrates.
- A `us_guardians` link table maps guardian → child.
The alternative — a standalone `us_students` table decoupled from `wp_users`
was rejected for v1: it changes the meaning of `student_id` on seven tables and
requires migrating every existing row.
## Data Model — `{prefix}us_guardians`
| Column | Type | Notes |
|----------------|-----------------|-----------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `guardian_id` | BIGINT UNSIGNED | WordPress user ID of the parent/guardian |
| `student_id` | BIGINT UNSIGNED | WordPress user ID of the child |
| `relationship` | VARCHAR(50) | Free text shown in admin (e.g. "Parent", "Grandparent"); may be empty |
| `created_at` | DATETIME | Insertion time |
`UNIQUE KEY guardian_student (guardian_id, student_id)` — the same pair can
never be linked twice.
The table is a link table, not a child record: the child's **name** is their
`display_name` on `wp_users`, and their birth year is the `us_birth_year`
user meta. Keeping them on the user row means the admin student screens,
`get_users()` ordering, and every existing `student_id` lookup keep working with
no special-casing.
### The legacy `us_date_of_birth` meta
This feature originally collected a full date of birth in `us_date_of_birth`.
Nothing writes that key any more. It is handled entirely inside
`GuardianService`:
- **Read** — `birthYear()` falls back to the year of the old date when
`us_birth_year` is absent, so a child added before the change still shows one
without a migration step.
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
including a save that clears the year. Without that the fallback would
resurrect the old date on the next read and the year could never be cleared.
The upshot is a lazy migration: a child's full date survives until their record
is next edited, then goes for good. There is no bulk purge — a site that wants
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
refuses to link a child that already has a guardian. The unique key and the
guardian-side lookups already support many-to-many, so adding a second guardian
(separated parents) later is an insert, not a migration.
## Schema changes to existing tables
| Table | Change | Why |
|---|---|---|
| `us_payments` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Who owes the money, when that is not the student |
| `us_credits` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Which account holds the balance |
| `us_policy_acceptances` | `accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0` | Who actually clicked, when that is not the student |
All three default to `0`, read back as "same as `student_id`" (see
`Payment::payerOrStudent()`, `Credit::payerOrStudent()`,
`PolicyAcceptance::acceptorOrStudent()`), so **an existing row keeps its current
meaning whatever happens** — a pre-guardian payment is still owed by, and was
still accepted by, the student it names.
The installer additionally backfills them (`PaymentRepository::backfillPayerIds()`,
`CreditRepository::backfillPayerIds()`, `AcceptanceRepository::backfillAcceptedBy()`,
run from `Installer::migrateData()`), because the *balance* lookups key on
`payer_id` directly and an indexed `WHERE payer_id = 5` would not see a legacy row
still holding `0`. The backfill is idempotent — it only touches rows still at `0`
and the `payerOrStudent()` fallbacks remain as the belt to its braces.
## Billing: the guardian is the payer, the child is the subject
- `us_payments.student_id` keeps naming **the child the lesson was for**, so
per-child payment reporting is unchanged.
- `us_payments.payer_id` names **the guardian who owes it**. Payment notices,
receipts and the Stripe intent all resolve the payer.
- `us_credits.payer_id` is where a **family balance** lives. A credit from one
child's cancelled lesson is held by the guardian and can settle a sibling's
charge; `CreditRepository::availableBalance()` and `consume()` operate on the
payer.
- The **billing-method override** (`comp` / `card` / `etransfer`, user meta read
by `BillingMethodResolver`) resolves against the payer, so comping a family is
one setting on the guardian rather than one per child.
`PaymentService::createForRegistration()` takes the payer id alongside the
student id; `BookingEndpoint` and `ScheduledBillingRunner` both pass
`GuardianService::payerFor( $studentId )` — the child's guardian when they have
one, otherwise the student themselves.
Family discounts are **out of scope** for v1 but are not designed out: with the
payer on both the payment and the credit ledger, a discount rule has a family to
apply to.
## Registration
A **"Who are you registering?"** choice on the existing `[us_student_register]`
form (all three signup paths — personal invite, group link, self-approval), as
three radios:
| Choice | `us_registering_for` | Student blocks | Account holder is a student | Answers the studio's questions |
|---|---|---|---|---|
| Just myself | `self` | no | yes | for themselves |
| On behalf of one or more students | `students` | yes | **no** | per student only |
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
The last column follows from the third, and is the whole of it: the
account-scope questions describe a *student* — instrument, level, school — so
they are asked of everyone being registered as one. Under `both` that is each
student **and** the account holder, whose answers are stored against their own
user id, not shared with anyone. Under `students` the account holder is not a
student, so anything posted for them is ignored outright.
Required answers are checked in two passes rather than one, so the error can say
whose are missing: `both` would otherwise have to blame "each student" for the
account holder's own blank field.
Radios rather than checkboxes because the three answers are mutually exclusive:
"both" only means anything as a third choice alongside the other two. Either
student-bearing choice requires at least one student name.
Anything unrecognised — a form posted without the field, an old cached page, a
crafted request — is read as `self`, the choice that collects the least and
grants the least. A missing radio must never be taken as "register these
children".
### The account holder as a student
This replaced a single "I'm registering as a parent or guardian" checkbox, which
could only say *whether there were children to add*. It could not say whether the
**account holder** was a student, so `bookableStudents()` always offered them
their own name and every guardian could book themselves a lesson nobody intended
to sell.
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
account holder out. The flag is stored as the **negative** deliberately: every
account predating the choice is a bookable student, and absence has to keep
meaning exactly that, or the picker would silently stop offering people
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
than writing `0`, so "not set" stays the one spelling of "yes, a student".
One guard: a guardian-only account with **nobody linked to it** is still offered
itself, because an empty picker is no way to book at all. They can put the
account right from the profile page.
Per child the form collects:
- **Name** (required)
- **Birth year** (required, `us_birth_year` meta) — a four-digit year between
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
definition of what counts, shared by the signup form's up-front validation and
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
than quietly discarded and a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian
answers them on the child's behalf; the answer row's `student_id` is the child.
### What the account holder gives when they are a student
Under `self` and `both` the account holder is a student too, so the **About you**
panel asks them for exactly the same two things every other student gives: their
**birth year** (`us_birth_year`, the same meta key and the same
`normaliseBirthYear()` rule — `GuardianService::setBirthYear()` writes both cases)
and the **account-scope questions**. Both are stored against their own user id.
The panel sits on the main form, above the students, rather than behind a "Next".
The questions used to be a second step, which put what the studio needs to know
about an adult student on a screen they reached only after everything else; now
one page holds one decision each — who you are registering, about you, about
them.
`register.js` takes the whole **About you** fieldset out of play under
`students`, by `disabled` as well as `hidden`: a disabled fieldset is neither
validated nor submitted, so a `required` field cannot block a form on a control
nobody can reach. The students block is toggled the same way, and the server
enforces both rules regardless — which is what makes them hold with JavaScript
off. The profile screen has no such problem: its forms are always visible, so the
attribute is static there.
Name and birth year are marked required in the labels the same way a required
question is; `[data-us-child-required]` keeps the attribute on the child fields
in step with the block they live in.
Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the account holder's own fields (email, password, policies, and —
when they are a student — their birth year and answers).
2. Validate **every** child block — a missing name, a missing or unusable birth
year, or a missing required per-child answer fails the whole submission
**before** any user is created, so a half-registered family is never left
behind. An **entirely empty** block is dropped instead, because the form
always renders one spare for "add another"; a block with anything at all
typed into it is kept and reported on, rather than silently discarding what
the guardian entered.
3. Create the guardian user, and record `us_guardian_only` and (when they are a
student) their birth year against it.
4. For each child: create the accountless user, link it, record its answers, and
record the signup policy acceptances **against the child** with
`accepted_by = <guardian>`.
5. Roll back — every child user created so far is deleted and the guardian user
with them — if any child creation fails, so a partial family never persists.
A guardian who does not tick the box registers exactly as before; nothing about
the single-student flow changes.
### Policy acceptance
`us_policy_acceptances` records **one row per child** for each signup-scoped
policy, with:
- `student_id` = the child (who the policy binds),
- `accepted_by` = the guardian (who actually agreed),
- `registration_type = 'account'`, `registration_id` = the child's user ID.
The guardian also gets their own acceptance row (`student_id = accepted_by =
guardian`) whether or not they book for themselves — they agreed to the terms as
an account holder. This is the legally meaningful record: "guardian X accepted
policy version N on behalf of child Y at time T from IP Z".
Booking-scope policies are accepted at booking time by whoever is signed in;
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
child.
## Managing children
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope
questions are asked here too, so a child added later carries the same
information as one added at signup.
- **Edit** updates `display_name` and `us_birth_year`.
- **Remove** unlinks the child and **deletes the child user**, but only when the
child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit
(`GuardianService::removeChild()`). The guardian is told to contact the studio
instead.
Submissions are processed on `template_redirect` (like registration) and
post/redirect/get back to the page, so a refresh cannot resubmit.
## Booking
`GET /bookings` returns the lessons of the signed-in user **and of every child
they are guardian for**, each row carrying `student_id` and `student_name` so
the list can be grouped by child.
`POST /bookings` takes an optional **`student_id`**:
- absent or `0` → the current user books for themselves (unchanged),
- a child's id → the endpoint verifies with `GuardianService::canActFor()` that
the current user is that child's guardian, and returns `403 forbidden` when
they are not. **This is the authorisation boundary of the feature**: without
it any student could book, and bill, against any user id they cared to send.
The booking form gains a "Who is this for?" `<select>`, rendered only when the
account has more than one person on it, so a single-student account's form is
unchanged.
**Children are listed first and the account holder last**
(`GuardianService::bookableStudents()`). The order is the whole point: a
guardian's normal case is booking for a child, so the default selection — the
one a parent gets by not touching the picker at all — is a child, never
themselves. 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. The guardian is still offered, last, so a parent taking lessons
alongside their children can book for themselves.
The list is rendered server-side into `data-students` on the page wrapper and
read by `assets/js/guardian.js`, which both the booking and group-class scripts
share.
`POST /bookings/<id>/cancel` accepts a cancellation from the lesson's student
**or** their guardian, subject to the same cancellation cutoff.
## Group classes
`POST /enrollments` carries the same optional `student_id` and the same
`canActFor()` check, `GET /enrollments` covers the household, and
`POST /enrollments/<id>/withdraw` accepts the guardian — group enrolment is the
other place a family books and pays, so it gets the identical treatment rather
than being left as a single-student-only path.
## Admin
- **Students list** gains a **Profile** column: a child links to its
guardian's detail screen, a guardian lists its children as links. Children are
listed alongside every other student rather than nested, so nothing about
finding a student changes.
- **Student detail** gains a **Profile** panel — the guardian (for a child) or
the children (for a guardian), each a link to the other's screen — and the
credit balance shown is the **payer's** balance, labelled with whose it is, so
an admin looking at a child sees the family balance that will actually settle
their charges rather than an empty per-child one.
- Registration answers and policy acceptances on a child's screen show
"accepted by <guardian>" where the acceptor differs from the student.
Creating or attaching a child from wp-admin is **out of scope** for v1; a studio
admin adds children through the guardian's own family screen or asks the
guardian to.
## Capabilities
No new capability. A child user holds the `us_student` role (so every existing
`student_id` capability check keeps working) but can never sign in
(`Guardian\ChildLoginGate` blocks `wp_authenticate_user` and forces
`user_has_cap` to withhold `book_lesson` from a child), so the role grants them
nothing in practice. Guardians act for children through
`GuardianService::canActFor()`, checked at every REST and form boundary, rather
than through a capability.
## Instructor view
Lesson lists show the student's name. Where that student is a child, the
instructor also sees the guardian's name and email — the contact they actually
need — via `GuardianService::contactFor()`.
## Implementation
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
(one question rendered under a caller-supplied input name, so the same
question can appear once per child without colliding)
- Front-end script: `assets/js/guardian.js` (the shared picker),
`assets/js/register.js` (guardian toggle + "add another child")
- Extended: `Auth\RegistrationPage` (guardian checkbox, child blocks, per-child
answers/acceptances, rollback), `Booking\BookingEndpoint` and
`GroupClass\EnrollmentEndpoint` (`student_id` param + guardian
authorisation, household listings), `Booking\BookingPage`,
`GroupClass\GroupClassPage`, `Payment\PaymentService`,
`Payment\PaymentRepository`, `Payment\CreditRepository`,
`Payment\ScheduledBillingRunner` (one notice per payer),
`Policy\AcceptanceRepository`, `Registration\RegistrationGate`,
`Auth\StudentController`, `Installer` (backfills)
- Schema: `us_guardians`; `us_payments.payer_id`; `us_credits.payer_id`;
`us_policy_acceptances.accepted_by`
## Tests
- `tests/Unit/Guardian/GuardianLinkTest.php`
- `tests/Unit/Guardian/GuardianRepositoryTest.php`
- `tests/Unit/Guardian/GuardianServiceTest.php`
- `tests/Unit/Guardian/ChildLoginGateTest.php`
- `tests/Unit/Guardian/FamilyPageTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php` (guardian signup path)
- `tests/Unit/Booking/BookingEndpointTest.php` and
`tests/Unit/GroupClass/EnrollmentEndpointTest.php` (booking/enrolling for a
child, and the 403 when the caller is not the guardian)
- `tests/Unit/Booking/BookingPageTest.php` (children lead the embedded list)
- `tests/Unit/Payment/PaymentServiceTest.php`, `CreditRepositoryTest.php`,
`ScheduledBillingRunnerTest.php` (payer, family balance, one notice)
## Related
`account-registration.md`, `lesson-booking.md`, `payments.md`, `credits.md`,
`group-classes.md`, `student-administration.md`, `policies.md`,
`registration-questions.md`.