Files
unsupervised-scheduler/docs/features/parent-guardian-accounts.md
T
thatguygriffandClaude Opus 5 4e5382e259
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Ask who the signup is for as a three-way choice
Replaces the single "I'm registering as a parent or guardian" tick with
"Just myself" / "On behalf of one or more students" / "Both".

Radios, not checkboxes as the feedback put it: the three answers are
mutually exclusive, and "both" only means anything as a third choice
alongside the other two.

The tick could only ever 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 any guardian could book themselves a
lesson nobody meant to sell. "On behalf of" now records us_guardian_only and
leaves them out of the picker.

That flag is stored as the negative on purpose. Every account predating this
choice is a bookable student, and absence has to keep meaning exactly that,
or the picker would quietly stop offering people themselves on upgrade.
setGuardianOnly() clears the key rather than writing 0, so "not set" stays
the single spelling of "yes, a student". A guardian-only account with nobody
linked to it is still offered itself — an empty picker is no way to book at
all, and they can put the account right from the profile page.

An unrecognised or absent value reads as "just myself": the choice that
collects the least and grants the least. A missing radio must never be taken
as "register these children".

Bumps to 1.4.0.

The account holder's own questions stay out of play whenever students are
being added, "both" included — asking them there is #146.

Verified the form in a headless browser across all three choices: which
blocks show, which fields carry `required`, whether the account holder's
question panel is disabled, which submit is offered, and that switching back
to "just myself" leaves no hidden required field blocking submit.

Closes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 23:09:47 -03:00

19 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 .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:

  • ReadbirthYear() 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.
  • WritesetBirthYear() 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
Just myself self no yes
On behalf of one or more students students yes no
Both — myself and one or more students both yes yes

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.

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():

  1. Validate the guardian's own fields (email, password, policies).
  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.
  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 " 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)

account-registration.md, lesson-booking.md, payments.md, credits.md, group-classes.md, student-administration.md, policies.md, registration-questions.md.