Files
unsupervised-scheduler/docs/features/parent-guardian-accounts.md
T
thatguygriffandClaude Opus 5 4a41ba96fb
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
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]>
2026-07-29 16:00:34 -03:00

15 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.

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