# 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-@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 **"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 = `. 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?" `