Let parents register once and book for their children

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]>
This commit is contained in:
2026-07-29 16:07:52 -03:00
co-authored by Claude Opus 5
parent c25260a367
commit b772e1811e
71 changed files with 4192 additions and 191 deletions
+9
View File
@@ -165,3 +165,12 @@ No-op when no registration page is set.
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
- `tests/Unit/Auth/RegistrationMailerTest.php`
- `tests/Unit/Payment/StudioSettingsTest.php`
## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, date of birth, and the
account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path
— personal invite, group link, and self-approval. See
`parent-guardian-accounts.md`.
+9
View File
@@ -121,3 +121,12 @@ refund of a shared payment.
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
## Family Balances
A credit records the student it was earned for (`student_id`) and the account
that **holds** it (`payer_id`). Balance lookups — `availableBalance()`,
`findAvailableByPayer()`, `consume()` — key on the payer, so a family shares one
balance and a credit from one child's cancelled lesson can settle a sibling's
next charge. A child's admin screen still lists the credits their own
cancellations produced, labelled with whose account holds the balance. See
`parent-guardian-accounts.md`.
+6
View File
@@ -194,3 +194,9 @@ class becomes enrollable for them — they choose whether to enrol.
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
## Enrolling A Child
`POST /enrollments` accepts the same optional **`student_id`** as booking,
authorised through `Guardian\GuardianService::canActFor()`; `GET /enrollments`
covers the guardian's whole household, and a guardian may withdraw any of their
children. See `parent-guardian-accounts.md`.
+9
View File
@@ -177,3 +177,12 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
- `tests/Unit/Booking/LessonControllerTest.php`
- `tests/Unit/Booking/LessonDetailTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php`
## Booking For Someone Else
A guardian books for their children from their own account. `POST /bookings`
accepts an optional **`student_id`**, honoured only when
`Guardian\GuardianService::canActFor()` confirms the caller is that student's
guardian — anything else is a `403`. The booking form's "Who is this for?" picker
lists **children first**, so the default selection is never the parent.
`GET /bookings` returns the whole household, and a guardian may cancel any of
their children's lessons. See `parent-guardian-accounts.md`.
+298
View File
@@ -0,0 +1,298 @@
# 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.
## 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 date of birth is the `us_date_of_birth`
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.
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 **"I'm registering as a parent or guardian"** checkbox on the existing
`[us_student_register]` form (all three signup paths — personal invite, group
link, self-approval) reveals a repeatable child block. Ticking it requires at
least one child name.
Per child the form collects:
- **Name** (required)
- **Date of birth** (optional, `us_date_of_birth` meta)
- **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.
Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the guardian's own fields (email, password, policies).
2. Validate **every** child block — a missing child name 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.
3. Create the guardian user.
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: **Family**) renders the guardian's manage-children screen:
list the children, add one, edit a name/date of birth, 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_date_of_birth`.
- **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 **Guardian / Children** 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 **Family** 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`.
+8
View File
@@ -200,3 +200,11 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
- `tests/Unit/Payment/PaymentTest.php`
- `tests/Unit/Payment/StripeGatewayTest.php`
- `tests/Unit/Payment/ReceiptMailerTest.php`
## Who Pays
`us_payments.student_id` names the student the charge is *for*;
`us_payments.payer_id` names who **owes** it — a child's guardian, or 0 meaning
the student pays for themselves (`Payment::payerOrStudent()`). The billing
method, receipts, payment notices and the Stripe payment step all resolve the
payer, so a family is billed and comped as one account while per-child reporting
is unchanged. See `parent-guardian-accounts.md`.
+6
View File
@@ -84,3 +84,9 @@ cover every policy's current version or the registration is rejected.
- `tests/Unit/Policy/PolicyServiceTest.php`
- `tests/Unit/Policy/PolicyControllerTest.php`
- `tests/Unit/Policy/PolicyEndpointTest.php`
## Who Accepted
`us_policy_acceptances.accepted_by` records the person who actually ticked the
box, where that differs from `student_id` — a guardian agreeing on a child's
behalf. It defaults to 0, read back as "the student agreed for themselves"
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
+9
View File
@@ -89,3 +89,12 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
## Per-Child Answers
For a parent/guardian signup, **account-scope** questions are asked **once per
child** rather than once per guardian — in practice they describe the student
(instrument, level, school), not the account holder. Each answer's `student_id`
and `registration_id` are the child's user ID, so a studio admin reading a
child's screen sees the information that describes them. The guardian's family
screen asks the same questions when a child is added later. See
`parent-guardian-accounts.md`.
+7
View File
@@ -94,3 +94,10 @@ applies that credit against their due charges before emailing the notice
- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
## One Notice Per Family
Charges are bucketed by **payer**, not student, so a guardian gets a single
notice covering every child rather than one email per child. Each line names the
student it is for when that is not the payer ("Ada: Piano Lesson — Mar 3, 2026"),
and account credit is applied across the whole bucket from the family balance.
See `parent-guardian-accounts.md`.
+7
View File
@@ -86,3 +86,10 @@ All actions are nonce-protected POSTs handled on the detail page:
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
`tests/Unit/Payment/PaymentRepositoryTest.php`
## Family Relationships
The students list gains a **Family** column — a child links to their guardian,
a guardian lists their children — and the student screen a **Family** panel. A
child's listed email is their guardian's, since a child's own address is an
undeliverable placeholder, and the credit balance shown is the payer's, labelled
with whose account holds it. See `parent-guardian-accounts.md`.