CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The Profile block is headed "Your profile", but the one person on it you could not change was yourself: your name, your birth year, and whether you take lessons yourself were fixed at whatever signup recorded, and correcting any of them meant asking a studio admin. A "Your details" section now opens the page, saved through the same nonce-checked template_redirect post/redirect/get path the child rows use: - Your name, written to display_name and nickname together, for the reason updateChild() does — UserName reads the nickname first, and leaving it behind would put the account's email address back on every screen that names a person. - "I take lessons myself", the positive of us_guardian_only. This makes good on the claim already in bookableStudents() and the feature doc that a guardian-only account can put itself right from the profile page. - Your birth year, held to the same normaliseBirthYear() rule as every other student. The email is shown but not editable: it is the account's user_login as well as its address, so changing it stays a studio-side job. The birth-year field deliberately carries no `required` attribute. It is asked of a student only, and this page loads no JavaScript, so a browser-enforced `required` would leave a guardian who books solely for other people unable to submit the form at all; handleSelf() enforces it against the checkbox instead. Unticking the box does not clear a stored birth year — it says who books, not "forget what is on file". Closes #165 Co-Authored-By: Claude Opus 5 <[email protected]>
453 lines
23 KiB
Markdown
453 lines
23 KiB
Markdown
# 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`:
|
|
|
|
- **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 | Answers the studio's questions |
|
|
|---|---|---|---|---|
|
|
| Just myself | `self` | no | yes | for themselves |
|
|
| On behalf of one or more students | `students` | yes | **no** | per student only |
|
|
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
|
|
|
|
The last column follows from the third, and is the whole of it: the
|
|
account-scope questions describe a *student* — instrument, level, school — so
|
|
they are asked of everyone being registered as one. Under `both` that is each
|
|
student **and** the account holder, whose answers are stored against their own
|
|
user id, not shared with anyone. Under `students` the account holder is not a
|
|
student, so anything posted for them is ignored outright.
|
|
|
|
Required answers are checked in two passes rather than one, so the error can say
|
|
whose are missing: `both` would otherwise have to blame "each student" for the
|
|
account holder's own blank field.
|
|
|
|
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 — see **Your details** below, where the flag
|
|
is editable as "I take lessons myself".
|
|
|
|
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.
|
|
|
|
### What the account holder gives when they are a student
|
|
|
|
Under `self` and `both` the account holder is a student too, so the **About you**
|
|
panel asks them for exactly the same two things every other student gives: their
|
|
**birth year** (`us_birth_year`, the same meta key and the same
|
|
`normaliseBirthYear()` rule — `GuardianService::setBirthYear()` writes both cases)
|
|
and the **account-scope questions**. Both are stored against their own user id.
|
|
|
|
The panel sits on the main form, above the students, rather than behind a "Next".
|
|
The questions used to be a second step, which put what the studio needs to know
|
|
about an adult student on a screen they reached only after everything else; now
|
|
one page holds one decision each — who you are registering, about you, about
|
|
them.
|
|
|
|
`register.js` takes the whole **About you** fieldset out of play under
|
|
`students`, by `disabled` as well as `hidden`: a disabled fieldset is neither
|
|
validated nor submitted, so a `required` field cannot block a form on a control
|
|
nobody can reach. The students block is toggled the same way, and the server
|
|
enforces both rules regardless — which is what makes them hold with JavaScript
|
|
off. The profile screen has no such problem: its forms are always visible, so the
|
|
attribute is static there.
|
|
|
|
Name and birth year are marked required in the labels the same way a required
|
|
question is; `[data-us-child-required]` keeps the attribute on the child fields
|
|
in step with the block they live in.
|
|
|
|
Order of operations in `RegistrationPage::handleSubmit()`:
|
|
|
|
1. Validate the account holder's own fields (email, password, policies, and —
|
|
when they are a student — their birth year and answers).
|
|
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, and record `us_guardian_only` and (when they are a
|
|
student) their birth year against it.
|
|
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 the account
|
|
|
|
### Your details
|
|
|
|
The profile screen opens with the account holder's own record, because the
|
|
alternative was a page called **Your profile** on which the one person who could
|
|
not be edited was you. The form saves through
|
|
`GuardianService::updateSelf()` and holds:
|
|
|
|
- **Your name** — `display_name` and `nickname`, written together for the reason
|
|
`updateChild()` does: `UserName` reads the nickname first, and leaving it
|
|
behind would put the account's email address back on every screen that names a
|
|
person.
|
|
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
|
|
asks the question the way a person answers it and `updateSelf()` is the one
|
|
place the sense is flipped. This is what makes good on "they can put the
|
|
account right from the profile page": an account that registered as a pure
|
|
guardian and later took up lessons — or ticked the wrong radio at signup — can
|
|
now correct itself instead of asking the studio to.
|
|
- **Your birth year** — `us_birth_year`, the same meta and the same
|
|
`normaliseBirthYear()` rule every student is held to.
|
|
|
|
Two decisions worth keeping:
|
|
|
|
- The birth-year field carries **no `required` attribute**. It is asked of a
|
|
student only, and the family screen loads no JavaScript, so a browser-enforced
|
|
`required` would leave a guardian who books solely for other people unable to
|
|
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
|
|
checkbox instead, which is where the condition actually lives.
|
|
- Unticking **I take lessons myself** does **not** clear a stored birth year.
|
|
The box says who books, not "forget what you know about me", and someone who
|
|
ticks it back on the next visit should find their details as they left them.
|
|
|
|
The **email** is shown but not editable: it is the account's `user_login` as
|
|
well as its address, so changing it is a studio-side job rather than a
|
|
profile-screen one.
|
|
|
|
The account-scope questions are not re-asked here, in either direction — the
|
|
child rows do not offer them on edit either, and a studio that needs a newly
|
|
self-declared student's answers asks for them the same way it would for any
|
|
other change of circumstance.
|
|
|
|
### Managing children
|
|
|
|
The rest of `[us_family]` (block: **Profile**) is the 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 <guardian>" 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, and the account
|
|
holder's own record via `accountHolder()`/`updateSelf()`)
|
|
- 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)
|
|
|
|
## Related
|
|
|
|
`account-registration.md`, `lesson-booking.md`, `payments.md`, `credits.md`,
|
|
`group-classes.md`, `student-administration.md`, `policies.md`,
|
|
`registration-questions.md`.
|