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