Let parents register once and book for their children
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
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
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]>
This commit is contained in:
@@ -165,3 +165,12 @@ No-op when no registration page is set.
|
||||
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
|
||||
- `tests/Unit/Auth/RegistrationMailerTest.php`
|
||||
- `tests/Unit/Payment/StudioSettingsTest.php`
|
||||
|
||||
## Parent/Guardian Signup
|
||||
The registration form also offers **"I'm registering as a parent or guardian"**,
|
||||
which reveals a repeatable child block (name, date of birth, and the
|
||||
account-scope questions asked **per child**). Each child becomes a login-less
|
||||
`us_student` user linked to the guardian, and the signup policies are recorded
|
||||
once per child with the guardian as the acceptor. Available on every signup path
|
||||
— personal invite, group link, and self-approval. See
|
||||
`parent-guardian-accounts.md`.
|
||||
|
||||
@@ -121,3 +121,12 @@ refund of a shared payment.
|
||||
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
|
||||
|
||||
## Family Balances
|
||||
A credit records the student it was earned for (`student_id`) and the account
|
||||
that **holds** it (`payer_id`). Balance lookups — `availableBalance()`,
|
||||
`findAvailableByPayer()`, `consume()` — key on the payer, so a family shares one
|
||||
balance and a credit from one child's cancelled lesson can settle a sibling's
|
||||
next charge. A child's admin screen still lists the credits their own
|
||||
cancellations produced, labelled with whose account holds the balance. See
|
||||
`parent-guardian-accounts.md`.
|
||||
|
||||
@@ -194,3 +194,9 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
|
||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
|
||||
|
||||
## Enrolling A Child
|
||||
`POST /enrollments` accepts the same optional **`student_id`** as booking,
|
||||
authorised through `Guardian\GuardianService::canActFor()`; `GET /enrollments`
|
||||
covers the guardian's whole household, and a guardian may withdraw any of their
|
||||
children. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -177,3 +177,12 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||
- `tests/Unit/Booking/LessonDetailTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
## Booking For Someone Else
|
||||
A guardian books for their children from their own account. `POST /bookings`
|
||||
accepts an optional **`student_id`**, honoured only when
|
||||
`Guardian\GuardianService::canActFor()` confirms the caller is that student's
|
||||
guardian — anything else is a `403`. The booking form's "Who is this for?" picker
|
||||
lists **children first**, so the default selection is never the parent.
|
||||
`GET /bookings` returns the whole household, and a guardian may cancel any of
|
||||
their children's lessons. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# 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 <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)
|
||||
- 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`.
|
||||
@@ -200,3 +200,11 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
||||
- `tests/Unit/Payment/PaymentTest.php`
|
||||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||||
- `tests/Unit/Payment/ReceiptMailerTest.php`
|
||||
|
||||
## Who Pays
|
||||
`us_payments.student_id` names the student the charge is *for*;
|
||||
`us_payments.payer_id` names who **owes** it — a child's guardian, or 0 meaning
|
||||
the student pays for themselves (`Payment::payerOrStudent()`). The billing
|
||||
method, receipts, payment notices and the Stripe payment step all resolve the
|
||||
payer, so a family is billed and comped as one account while per-child reporting
|
||||
is unchanged. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -84,3 +84,9 @@ cover every policy's current version or the registration is rejected.
|
||||
- `tests/Unit/Policy/PolicyServiceTest.php`
|
||||
- `tests/Unit/Policy/PolicyControllerTest.php`
|
||||
- `tests/Unit/Policy/PolicyEndpointTest.php`
|
||||
|
||||
## Who Accepted
|
||||
`us_policy_acceptances.accepted_by` records the person who actually ticked the
|
||||
box, where that differs from `student_id` — a guardian agreeing on a child's
|
||||
behalf. It defaults to 0, read back as "the student agreed for themselves"
|
||||
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -89,3 +89,12 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
- `tests/Unit/Registration/AnswerTest.php`
|
||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php`
|
||||
|
||||
## Per-Child Answers
|
||||
For a parent/guardian signup, **account-scope** questions are asked **once per
|
||||
child** rather than once per guardian — in practice they describe the student
|
||||
(instrument, level, school), not the account holder. Each answer's `student_id`
|
||||
and `registration_id` are the child's user ID, so a studio admin reading a
|
||||
child's screen sees the information that describes them. The guardian's family
|
||||
screen asks the same questions when a child is added later. See
|
||||
`parent-guardian-accounts.md`.
|
||||
|
||||
@@ -94,3 +94,10 @@ applies that credit against their due charges before emailing the notice
|
||||
- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
|
||||
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
|
||||
|
||||
## One Notice Per Family
|
||||
Charges are bucketed by **payer**, not student, so a guardian gets a single
|
||||
notice covering every child rather than one email per child. Each line names the
|
||||
student it is for when that is not the payer ("Ada: Piano Lesson — Mar 3, 2026"),
|
||||
and account credit is applied across the whole bucket from the family balance.
|
||||
See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -86,3 +86,10 @@ All actions are nonce-protected POSTs handled on the detail page:
|
||||
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
|
||||
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
|
||||
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||
|
||||
## Family Relationships
|
||||
The students list gains a **Family** column — a child links to their guardian,
|
||||
a guardian lists their children — and the student screen a **Family** panel. A
|
||||
child's listed email is their guardian's, since a child's own address is an
|
||||
undeliverable placeholder, and the credit balance shown is the payer's, labelled
|
||||
with whose account holds it. See `parent-guardian-accounts.md`.
|
||||
|
||||
Reference in New Issue
Block a user