CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Signup and the profile page now ask for a four-digit year between 1900 and the current year. Anything else — a short year, a full date, a year in the future — is discarded rather than stored, so a typo cannot leave a nonsense age on the record. The year lives in a new us_birth_year user meta rather than reusing us_date_of_birth, which would have left one key holding two formats. The old key is not migrated in bulk. Instead GuardianService handles it in two halves: birthYear() falls back to the year of the old date when the new key is absent, so a student added before this change still shows one, and setBirthYear() deletes the old date on every save. That deletion is what makes the fallback safe rather than merely tidy. Without it, clearing the birth year on a student who predates the change would leave the old date behind for the fallback to read straight back, and the year could never be cleared at all. Stored in user meta, so no Schema.php change and no USC_VERSION bump. Closes #147 Co-Authored-By: Claude Opus 5 <[email protected]>
331 lines
17 KiB
Markdown
331 lines
17 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 **"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)
|
|
- **Birth year** (optional, `us_birth_year` meta) — a four-digit year between
|
|
1900 and the current year. Anything else is discarded rather than stored, so
|
|
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.
|
|
|
|
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: **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`.
|