Both fields are marked in their labels the same way a required registration question is, and enforced on the server whichever form they arrive from: GuardianService::createChild() and updateChild() now refuse a blank name or an unusable birth year, and the signup form checks the same rule up front, before it creates a single user, so a bad block never leaves a half-registered family behind. normaliseBirthYear() became public and static so both paths share one definition of what a usable year is. The signup form cannot lean on the browser here. Its child blocks are hidden until the parent/guardian box is ticked, and a `required` field inside a hidden container makes the whole form unsubmittable with no control the user can reach to fix — the same trap the guardian's own question panel already sidesteps by disabling rather than hiding. So register.js puts `required` on and takes it off along with the block itself, and the server is what makes the rule hold with JavaScript off. The profile screen has no such problem: its forms are always visible, so the attribute is static there. One behaviour change beyond the requirement: a child block with anything typed into it is now reported back instead of dropped. Previously any block without a name was silently discarded, which would now mean losing a birth year the guardian had filled in. A wholly untouched spare block — the one the form always renders for "add another" — is still ignored. Verified the required-toggling in a headless browser: unticked submits, ticked blocks an empty block, a cloned block inherits the requirement, and re-unticking leaves nothing behind to block a non-guardian signup. Closes #148 Co-Authored-By: Claude Opus 5 <[email protected]>
18 KiB
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
.invalidTLD (us-child-<random>@child.invalid, seeGuardianService::childEmail()) — a well-formed address that can never resolve, so nothing about a child's account can be emailed somewhere real, - the
us_childuser meta flag set to1, whichGuardian\ChildLoginGateuses to block authentication outright.
Consequences:
us_lessons,us_group_enrollments,us_question_answersandus_group_accessare 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_guardianslink 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 whenus_birth_yearis absent, so a child added before the change still shows one without a migration step. - Write —
setBirthYear()deletesus_date_of_birthon 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_idkeeps naming the child the lesson was for, so per-child payment reporting is unchanged.us_payments.payer_idnames the guardian who owes it. Payment notices, receipts and the Stripe intent all resolve the payer.us_credits.payer_idis 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()andconsume()operate on the payer.- The billing-method override (
comp/card/etransfer, user meta read byBillingMethodResolver) 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 (required,
us_birth_yearmeta) — 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 bycreateChild()/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'sstudent_idis the child.
Name and birth year are marked required in the labels the same way a required
question is, but the signup form cannot lean on the browser to enforce them:
the child blocks are hidden until the parent/guardian box is ticked, and a
required field inside a hidden container makes the form unsubmittable with no
control the user can reach to fix. register.js therefore puts required on
and takes it off along with the block itself ([data-us-child-required]), and
the server checks regardless — which is what makes the rule hold with
JavaScript off. The profile screen has no such problem: its forms are always
visible, so the attribute is static there.
Order of operations in RegistrationPage::handleSubmit():
- Validate the guardian's own fields (email, password, policies).
- 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.
- Create the guardian user.
- 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>. - 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_nameandus_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 returns403 forbiddenwhen 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 " 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\BookingEndpointandGroupClass\EnrollmentEndpoint(student_idparam + 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.phptests/Unit/Guardian/GuardianRepositoryTest.phptests/Unit/Guardian/GuardianServiceTest.phptests/Unit/Guardian/ChildLoginGateTest.phptests/Unit/Guardian/FamilyPageTest.phptests/Unit/Auth/RegistrationPageTest.php(guardian signup path)tests/Unit/Booking/BookingEndpointTest.phpandtests/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.