Files
unsupervised-scheduler/docs/features/parent-guardian-accounts.md
T
thatguygriffandClaude Opus 5 98e1bfb2f9 Require a name and birth year for every student
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]>
2026-07-29 21:00:29 -03:00

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 .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 "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_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.