From b772e1811e88f6725510da2870ccc2440650cde8 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 29 Jul 2026 16:00:34 -0300 Subject: [PATCH 1/2] Let parents register once and book for their children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 12 +- assets/css/frontend.css | 102 +++++ assets/js/blocks.js | 22 ++ assets/js/booking.js | 18 +- assets/js/group-classes.js | 6 + assets/js/guardian.js | 68 ++++ assets/js/register.js | 122 +++++- docs/features/account-registration.md | 9 + docs/features/credits.md | 9 + docs/features/group-classes.md | 6 + docs/features/lesson-booking.md | 9 + docs/features/parent-guardian-accounts.md | 298 +++++++++++++++ docs/features/payments.md | 8 + docs/features/policies.md | 6 + docs/features/registration-questions.md | 9 + docs/features/scheduled-billing.md | 7 + docs/features/student-administration.md | 7 + src/AdminMenu.php | 5 +- src/Auth/RegistrationPage.php | 145 ++++++- src/Auth/StudentController.php | 21 +- src/BlockPreview.php | 35 ++ src/BlockRegistrar.php | 20 + src/Booking/BookingEndpoint.php | 94 ++++- src/Booking/BookingPage.php | 8 + src/GroupClass/EnrollmentEndpoint.php | 66 +++- src/GroupClass/GroupClassPage.php | 7 + src/Guardian/ChildLoginGate.php | 62 +++ src/Guardian/FamilyPage.php | 284 ++++++++++++++ src/Guardian/GuardianLink.php | 47 +++ src/Guardian/GuardianRepository.php | 122 ++++++ src/Guardian/GuardianService.php | 358 ++++++++++++++++++ src/Installer.php | 11 + src/Payment/Credit.php | 17 + src/Payment/CreditRepository.php | 46 ++- src/Payment/Payment.php | 28 ++ src/Payment/PaymentRepository.php | 15 +- src/Payment/PaymentService.php | 53 ++- src/Payment/ScheduledBillingRunner.php | 48 ++- src/Plugin.php | 25 +- src/Policy/AcceptanceRepository.php | 16 +- src/Policy/PolicyAcceptance.php | 27 ++ src/Registration/QuestionField.php | 61 +++ src/Registration/RegistrationGate.php | 7 +- src/RestRegistrar.php | 7 +- src/Schema.php | 21 + src/ShortcodeRegistrar.php | 13 +- templates/admin/student-detail.php | 48 ++- templates/admin/students.php | 39 +- templates/frontend/booking-page.php | 8 +- templates/frontend/family-page.php | 107 ++++++ templates/frontend/group-classes-page.php | 7 +- templates/frontend/register-page.php | 92 +++-- tests/Unit/Auth/RegistrationPageTest.php | 193 ++++++++++ tests/Unit/Auth/StudentHistoryTest.php | 10 +- tests/Unit/BlockRegistrarTest.php | 8 +- tests/Unit/Booking/BookingEndpointTest.php | 155 +++++++- tests/Unit/Booking/BookingPageTest.php | 42 +- .../GroupClass/EnrollmentEndpointTest.php | 90 ++++- tests/Unit/GroupClass/GroupClassPageTest.php | 11 +- tests/Unit/Guardian/ChildLoginGateTest.php | 122 ++++++ tests/Unit/Guardian/FamilyPageTest.php | 276 ++++++++++++++ tests/Unit/Guardian/GuardianLinkTest.php | 57 +++ .../Unit/Guardian/GuardianRepositoryTest.php | 126 ++++++ tests/Unit/Guardian/GuardianServiceTest.php | 296 +++++++++++++++ tests/Unit/Payment/CreditRepositoryTest.php | 65 +++- tests/Unit/Payment/PaymentServiceTest.php | 105 ++++- .../Payment/ScheduledBillingRunnerTest.php | 115 +++++- tests/Unit/Payment/StripeGatewayTest.php | 2 +- .../Unit/Policy/AcceptanceRepositoryTest.php | 6 +- tests/Unit/ShortcodeRegistrarTest.php | 12 +- unsupervised-schedular.php | 4 +- 71 files changed, 4192 insertions(+), 191 deletions(-) create mode 100644 assets/js/guardian.js create mode 100644 docs/features/parent-guardian-accounts.md create mode 100644 src/Guardian/ChildLoginGate.php create mode 100644 src/Guardian/FamilyPage.php create mode 100644 src/Guardian/GuardianLink.php create mode 100644 src/Guardian/GuardianRepository.php create mode 100644 src/Guardian/GuardianService.php create mode 100644 src/Registration/QuestionField.php create mode 100644 templates/frontend/family-page.php create mode 100644 tests/Unit/Guardian/ChildLoginGateTest.php create mode 100644 tests/Unit/Guardian/FamilyPageTest.php create mode 100644 tests/Unit/Guardian/GuardianLinkTest.php create mode 100644 tests/Unit/Guardian/GuardianRepositoryTest.php create mode 100644 tests/Unit/Guardian/GuardianServiceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index baabcda..90b0b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,17 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching the plugin to the next patch version and adds a fresh section here for it. Record each change under the current top section as you work. -## [1.2.5] +## [1.3.0] + +### Added +- **Parent and guardian accounts.** A parent registers once and manages lessons for one or more children, who need no login of their own. The signup form gains an **"I'm registering as a parent or guardian"** tick that reveals a block per child — name, date of birth, and the studio's account-signup questions asked **per child**, since those describe the student rather than the account holder. Signup policies are recorded once per child with the guardian named as the person who agreed, which is the record that actually means something: "this guardian accepted version N on behalf of this child, at this time, from this address." A guardian can also be a student themselves and book their own lessons from the same account. +- A **"Who is this for?"** picker on the booking and group-class forms, listing **children first** and the account holder last — so the default selection is never the parent, and a lesson meant for a child is not quietly booked and billed in the parent's name. An account with only itself on the list sees no picker and behaves exactly as before. A guardian's upcoming-lessons panel covers the whole household, each row naming whose lesson it is, and they can cancel or withdraw for any of their children. +- A **Family** page for guardians (`[us_family]`, or the **Family** block) to add, edit and remove children after signup. Removing a child is refused once they have lessons or enrolments on record — that history belongs to them, and the studio unpicks it by hand rather than the page orphaning it. +- **One family, one bill.** Payments record the child the lesson was for *and* the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step all go to the parent. Account credit is held by the payer, so a credit from one child's cancelled lesson can settle a sibling's next charge, and the billing-method override (comp / card / e-transfer) is one setting on the guardian rather than one per child. The daily billing scan sends a guardian **one** notice covering every child, with each line naming whose lesson it is. +- **Students** in wp-admin gains a **Family** column linking a child to their guardian and a guardian to their children, and the student screen gains a **Family** panel. A child's row shows the guardian's email — a child's own address is a placeholder that can never receive mail — and their credit balance is labelled with whose account actually holds it. + +### Changed +- Child accounts **cannot be signed in to**. They hold the student role so every existing lookup keeps working, but authentication is refused outright and the booking capability is withheld, so the only route to a lesson in a child's name is their guardian's authorised booking. ## [1.2.4] diff --git a/assets/css/frontend.css b/assets/css/frontend.css index e305d00..c76504b 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -388,6 +388,108 @@ } } +/* + * "Who is this for?" picker — booking and enrolment. Present only on an account + * that books for more than one person, so it is styled as a normal field rather + * than a callout. + */ +.us-student-picker select { + max-width: 100%; +} + +/* Whose lesson a row in the upcoming panel is — only shown on a family account. */ +.us-my-lesson-who { + font-weight: normal; + opacity: 0.75; +} + +/* Parent/guardian signup: the child blocks revealed by the checkbox. */ +.us-guardian { + margin: 16px 0; + padding: 12px 14px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.us-guardian legend { + padding: 0 6px; + font-weight: 600; +} + +.us-children-intro { + margin-top: 0; + font-size: 0.9em; + opacity: 0.8; +} + +/* + * Each child is a bordered group so a family of three does not read as one long + * undifferentiated column of fields. + */ +.us-child { + margin-bottom: 12px; + padding: 10px 12px; + border-left: 3px solid #ddd; + background: #fafafa; +} + +.us-child > p:last-child { + margin-bottom: 0; +} + +/* The guardian's manage-children screen ([us_family]). */ +.us-family-list { + margin: 0 0 20px; + padding: 0; + list-style: none; +} + +.us-family-child { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: baseline; + padding: 10px 0; + border-bottom: 1px solid #eee; +} + +.us-family-child-name { + font-weight: 600; +} + +.us-family-child-dob { + font-size: 0.9em; + opacity: 0.75; +} + +/* + * The actions sit at the far end of the row. Remove is its own form (it posts), + * so it is forced inline rather than taking a block of its own. + */ +.us-family-child-actions { + display: flex; + gap: 10px; + align-items: baseline; + margin-left: auto; +} + +.us-family-remove { + display: inline; +} + +/* The editing row replaces the child's line, so it spans the whole width. */ +.us-family-edit { + width: 100%; +} + +@media (max-width: 640px) { + /* A name, a date and two actions do not fit one narrow line. */ + .us-family-child-actions { + margin-left: 0; + width: 100%; + } +} + /* Shown only in block-editor previews (see BlockPreview). */ .us-editor-note { font-size: 0.85em; diff --git a/assets/js/blocks.js b/assets/js/blocks.js index 7c55aaf..2ce1ff0 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -282,6 +282,28 @@ }) ), }, + { + name: 'us-scheduler/family', + title: __('Family', 'unsupervised-schedular'), + description: __('Lets a parent or guardian add, edit and remove the children they book lessons for.', 'unsupervised-schedular'), + icon: 'groups', + keywords: ['family', 'children', 'guardian', 'parent'], + shortcode: 'us_family', + attributes: { + loginPageId: { type: 'number', default: 0 }, + }, + inspector: (attributes, setAttributes) => el( + PanelBody, + { title: __('Logged-out visitors', 'unsupervised-schedular') }, + el(PageSelect, { + label: __('Login page', 'unsupervised-schedular'), + help: __('Where visitors who are not signed in are sent to log in.', 'unsupervised-schedular'), + defaultLabel: __('WordPress login screen', 'unsupervised-schedular'), + value: attributes.loginPageId, + onChange: (loginPageId) => setAttributes({ loginPageId }), + }) + ), + }, ]; blocks.forEach((def) => { diff --git a/assets/js/booking.js b/assets/js/booking.js index aa51ff7..af7d9a7 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -16,6 +16,11 @@ const pinnedTypeId = Number(app.dataset.lessonType) || 0; const filterEnabled = app.dataset.typeFilter !== '0'; + // Who this account may book for — children first, the account holder last, + // so a guardian's default selection is a child rather than themselves. A + // single-student account has one entry and gets no picker. + const students = window.usGuardian.parseStudents(app.dataset.students); + function apiFetch(path, options = {}) { return fetch(restUrl + path, { ...options, @@ -453,6 +458,7 @@

${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}

+ ${window.usGuardian.selectorHtml(students, 'us-booking-student')} ${offeringFieldHtml(tied, tiedId, choices)}
${policies.map(policyField).join('')} @@ -553,6 +559,7 @@ body: JSON.stringify({ slot_id: slot.id, offering_id: offeringId, + student_id: window.usGuardian.selectedId('us-booking-student'), recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single', answers, accepted_policy_version_ids: accepted, @@ -579,6 +586,15 @@ // How many upcoming lessons to show before the "Show all" reveal. const INITIAL_LESSON_COUNT = 5; + // Whose lesson this is. Only shown on an account that books for more than + // one person — on a single-student account the name is on every row and says + // nothing. + function lessonWhoHtml(l) { + if (students.length < 2 || !l.student_name) return ''; + + return ` — ${escHtml(String(l.student_name))}`; + } + function lessonRowHtml(l) { const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson'; const duration = l.duration_minutes ? ` (${escHtml(String(l.duration_minutes))} min)` : ''; @@ -588,7 +604,7 @@ return `
- ${title}${duration} + ${title}${duration}${lessonWhoHtml(l)} ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}
diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index e43248a..ede66ef 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -17,6 +17,10 @@ // enrolment controls. const singleOfferingId = Number(app.dataset.offering || 0); + // Who this account may enrol — children first, the account holder last, so a + // guardian's default selection is a child. One entry means no picker. + const students = window.usGuardian.parseStudents(app.dataset.students); + function apiFetch(path, options = {}) { return fetch(restUrl + path, { ...options, @@ -202,6 +206,7 @@

${escHtml(offering.title)}

+ ${window.usGuardian.selectorHtml(students, 'us-enrol-student')} ${questions.map(questionField).join('')} ${policies.map(policyField).join('')} ${window.usPricing.summaryHtml(offering)} @@ -239,6 +244,7 @@ method: 'POST', body: JSON.stringify({ offering_id: offering.id, + student_id: window.usGuardian.selectedId('us-enrol-student'), answers, accepted_policy_version_ids: accepted, }), diff --git a/assets/js/guardian.js b/assets/js/guardian.js new file mode 100644 index 0000000..af06a1b --- /dev/null +++ b/assets/js/guardian.js @@ -0,0 +1,68 @@ +/** + * "Who is this for?" picker, shared by the lesson-booking and group-class + * registration forms. + * + * The list arrives from the server already ordered children-first, with the + * account holder last, and this module preserves that order: a guardian's + * default selection is their first child, never themselves. Booking for the + * wrong child is a correctable mistake; quietly enrolling the parent in a class + * meant for their kid is not. + */ +(function () { + 'use strict'; + + function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** + * Read the server-rendered student list off a `data-students` attribute. + * Anything unparseable degrades to an empty list, which renders no picker + * and books for the signed-in user — the pre-guardian behaviour. + */ + function parseStudents(raw) { + if (!raw) return []; + try { + const list = JSON.parse(raw); + return Array.isArray(list) ? list : []; + } catch (e) { + return []; + } + } + + /** + * The picker's markup, or an empty string when there is nothing to choose: + * an account with only itself on the list never sees the question. + */ + function selectorHtml(students, id) { + if (!students || students.length < 2) return ''; + + const options = students.map((s) => { + // The account holder reads as "Myself" — their own name next to their + // children's is ambiguous about which row is the parent. + const label = s.is_self ? `Myself (${s.name})` : s.name; + return ``; + }).join(''); + + return ` +

+ +

`; + } + + /** + * The chosen student id, or 0 when no picker was rendered — the server + * reads 0 as "the caller books for themselves". + */ + function selectedId(id) { + const el = document.getElementById(id); + return el ? Number(el.value) || 0 : 0; + } + + window.usGuardian = { parseStudents, selectorHtml, selectedId }; +}()); diff --git a/assets/js/register.js b/assets/js/register.js index ca56f1c..93b8364 100644 --- a/assets/js/register.js +++ b/assets/js/register.js @@ -1,23 +1,31 @@ /** - * Progressive enhancement for the two-step student registration form. + * Progressive enhancement for the student registration form. * - * When account-signup questions are configured the form renders two panels - * (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a - * single form marked `data-steps="1"`. This script hides step two behind a - * "Next" button that only advances once step one passes native validation. - * Without JS both panels stay visible and the single submit still works. + * Two independent behaviours, both optional — without JS every panel stays + * visible and the single submit still works: + * + * 1. **Two steps.** When account-signup questions are configured the form + * renders two panels (`[data-step="1"]` account details, `[data-step="2"]` + * the questions) inside a form marked `data-steps="1"`. Step two is hidden + * behind a "Next" button that only advances once step one passes native + * validation. + * 2. **Parent/guardian.** The children section is hidden until the + * parent/guardian box is ticked, and "Add another child" clones the child + * block. Ticking the box also takes the guardian's *own* question panel out + * of play — in guardian mode the questions are asked per child, so the + * server ignores those answers and the browser must not demand them. */ (function () { 'use strict'; - function enhance(form) { + function enhanceSteps(form) { var step1 = form.querySelector('[data-step="1"]'); var step2 = form.querySelector('[data-step="2"]'); var next = form.querySelector('.us-reg-next'); var back = form.querySelector('.us-reg-back'); if (!step1 || !step2 || !next) { - return; + return null; } function show(step) { @@ -45,13 +53,107 @@ show(1); }); } + + return { + step2: step2, + next: next, + earlySubmit: form.querySelector('.us-reg-submit-early'), + }; + } + + /** + * Rewrite a cloned child block's `children[0][…]` names and ids to the new + * index, and clear the values carried over from the block it was cloned from. + */ + function reindex(block, index) { + block.setAttribute('data-child-index', String(index)); + + var fields = block.querySelectorAll('input, select, textarea'); + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + + if (field.name) { + field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']'); + } + + var oldId = field.id; + if (oldId) { + field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-'); + + var label = block.querySelector('label[for="' + oldId + '"]'); + if (label) { + label.setAttribute('for', field.id); + } + } + + if (field.type === 'checkbox' || field.type === 'radio') { + field.checked = false; + } else { + field.value = ''; + } + } + } + + function enhanceGuardian(form, steps) { + var toggle = form.querySelector('#us-is-guardian'); + var children = form.querySelector('#us-children'); + + if (!toggle || !children) { + return; + } + + var addButton = children.querySelector('.us-add-child'); + var nextIndex = 1; + + // The guardian's own question panel is only meaningful when they are + // registering for themselves. Disabling it (rather than hiding it) is what + // stops a `required` question the server will ignore from blocking submit. + function sync() { + children.hidden = !toggle.checked; + + if (!steps) { + return; + } + + var fields = steps.step2.querySelectorAll('input, select, textarea'); + for (var i = 0; i < fields.length; i++) { + fields[i].disabled = toggle.checked; + } + + // With the questions out of play there is no second step to advance to, + // so "Next" would be a dead end — swap it for the submit. + steps.next.hidden = toggle.checked; + + if (steps.earlySubmit) { + steps.earlySubmit.hidden = !toggle.checked; + } + } + + toggle.addEventListener('change', sync); + sync(); + + if (addButton) { + addButton.addEventListener('click', function () { + var blocks = children.querySelectorAll('.us-child'); + var clone = blocks[blocks.length - 1].cloneNode(true); + + reindex(clone, nextIndex); + nextIndex += 1; + + children.insertBefore(clone, addButton.parentNode); + }); + } } document.addEventListener('DOMContentLoaded', function () { - var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]'); + var forms = document.querySelectorAll('.us-register-form form'); for (var i = 0; i < forms.length; i++) { - enhance(forms[i]); + var steps = forms[i].getAttribute('data-steps') === '1' + ? enhanceSteps(forms[i]) + : null; + + enhanceGuardian(forms[i], steps); } }); })(); diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 96edd27..4cea3b2 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -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`. diff --git a/docs/features/credits.md b/docs/features/credits.md index be077fc..cafe93a 100644 --- a/docs/features/credits.md +++ b/docs/features/credits.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`. diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 1f01d72..99207d7 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.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`. diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index af19038..97771af 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.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`. diff --git a/docs/features/parent-guardian-accounts.md b/docs/features/parent-guardian-accounts.md new file mode 100644 index 0000000..1d6baf8 --- /dev/null +++ b/docs/features/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-@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: **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?" `

' + . '

' + . '

', + esc_html__( 'Add a child', 'unsupervised-schedular' ), + esc_html__( 'Name', 'unsupervised-schedular' ), + esc_html__( 'Date of birth', 'unsupervised-schedular' ), + esc_html__( 'Add child', 'unsupervised-schedular' ) + ); + + return sprintf( + '
%s

%s

    %s
%s
', + self::note( __( 'Editor preview — signed-in guardians see and manage their own children here.', 'unsupervised-schedular' ) ), + esc_html__( 'Your family', 'unsupervised-schedular' ), + $children, + $add + ); + } + private static function note( string $text ): string { return '

' . esc_html( $text ) . '

'; } diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php index 203735e..f97c3c6 100644 --- a/src/BlockRegistrar.php +++ b/src/BlockRegistrar.php @@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\FamilyPage; /** * Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so @@ -28,6 +29,7 @@ class BlockRegistrar { private LoginPage $loginPage, private RegistrationPage $registrationPage, private GroupClassPage $groupClassPage, + private FamilyPage $familyPage, ) {} public function register(): void { @@ -137,6 +139,15 @@ class BlockRegistrar { ], ], ], + 'us-scheduler/family' => [ + 'render' => [ $this, 'renderFamily' ], + 'attributes' => [ + 'loginPageId' => [ + 'type' => 'number', + 'default' => 0, + ], + ], + ], ]; } @@ -184,6 +195,15 @@ class BlockRegistrar { return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 ); } + /** + * Renders the family (manage-children) block. + * + * @param array $attributes Block attributes. + */ + public function renderFamily( array $attributes = [] ): string { + return $this->isEditorPreview() ? BlockPreview::family() : $this->familyPage->render( $attributes ); + } + /** * Server-side auto-redirect for blocks that opt in via their autoRedirect * attribute: logged-out visitors on a page containing the booking block diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 486dc55..ba4cd74 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; @@ -28,6 +29,7 @@ class BookingEndpoint { private RegistrationGate $gate, private PaymentService $payments, private CancellationPolicy $cancellationPolicy, + private GuardianService $guardians, ) {} /** @@ -59,6 +61,13 @@ class BookingEndpoint { 'type' => 'integer', 'default' => 0, ], + // Who the lesson is for. 0/absent means the caller books for + // themselves; a child's id is honoured only for their guardian. + 'student_id' => [ + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ], 'recurrence' => [ 'type' => 'string', 'default' => 'single', @@ -114,12 +123,26 @@ class BookingEndpoint { } public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - $userId = get_current_user_id(); - $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) - ? $this->bookings->findUpcomingForInstructor( $userId ) - : $this->bookings->findUpcomingForStudent( $userId ); + $userId = get_current_user_id(); - return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 ); + if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) { + $lessons = $this->bookings->findUpcomingForInstructor( $userId ); + } else { + // A guardian's list covers the whole household — their own lessons and + // every child's — merged and re-sorted so the soonest is first + // regardless of whose it is. + $lessons = []; + foreach ( $this->guardians->householdIds( $userId ) as $studentId ) { + $lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) ); + } + } + + $rows = array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ); + + // usort reindexes in place, so the response is already a list. + usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) ); + + return new \WP_REST_Response( $rows, 200 ); } /** @@ -144,10 +167,21 @@ class BookingEndpoint { 'end_dt' => $slot?->endDt, 'offering_title' => $offering?->title, 'duration_minutes' => $duration, + // Whose lesson it is, so a guardian's merged list can say which child + // each row belongs to. + 'student_name' => $this->guardians->studentName( $lesson->studentId ), ]; } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + // Who the lesson is for is settled before anything else is touched: an + // unauthorised student id must never get as far as claiming a slot, and + // certainly never as far as raising a payment against someone's account. + $studentId = $this->resolveStudent( $request ); + if ( $studentId instanceof \WP_Error ) { + return $studentId; + } + $slotId = Val::int( $request->get_param( 'slot_id' ) ); $slot = $this->availability->findById( $slotId ); @@ -214,7 +248,6 @@ class BookingEndpoint { return $gateError; } - $studentId = get_current_user_id(); $notes = Val::string( $request->get_param( 'notes' ) ); $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY @@ -254,7 +287,9 @@ class BookingEndpoint { $ids = [ $anchorId ]; } - $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + // The acceptance binds the student but is attributed to whoever actually + // ticked the boxes — the guardian, when they booked for a child. + $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() ); $payment = null; $status = Lesson::STATUS_PENDING; @@ -276,7 +311,16 @@ class BookingEndpoint { ? $offering->price : $offering->price * count( $ids ); - $payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail ); + $payment = $this->payments->createForRegistration( + Payment::REG_LESSON, + $anchorId, + $studentId, + $slot->instructorId, + $amount, + $offering->currency, + $offering->etransferEmail, + payerId: $this->guardians->payerFor( $studentId ) + ); if ( null !== $payment && $payment->isPaid() ) { $status = Lesson::STATUS_CONFIRMED; @@ -303,6 +347,35 @@ class BookingEndpoint { ); } + /** + * Who this booking is for: the caller by default, or one of their children + * when a `student_id` is supplied and they are that child's guardian. + * + * This is the authorisation boundary of guardian booking — without it any + * signed-in student could book, and bill, against any user id they chose to + * send. An id the caller may not act for is a 403, never a silent fallback to + * themselves: a guardian who picked the wrong child needs to be told, not to + * have the lesson quietly booked in their own name. + */ + private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error { + $userId = get_current_user_id(); + $requested = absint( Val::int( $request->get_param( 'student_id' ) ) ); + + if ( $requested <= 0 || $requested === $userId ) { + return $userId; + } + + if ( ! $this->guardians->canActFor( $userId, $requested ) ) { + return new \WP_Error( + 'forbidden', + __( 'You cannot book on behalf of that student.', 'unsupervised-schedular' ), + [ 'status' => 403 ] + ); + } + + return $requested; + } + /** * Extract a question_id => value map from the request. * @@ -342,7 +415,8 @@ class BookingEndpoint { } /** - * Student-initiated cancellation of their own lesson: marks it cancelled, + * Student-initiated cancellation of their own lesson — or a guardian's, of one + * of their children's: marks it cancelled, * frees the slot for rebooking, and voids any still-pending payment. A lesson * already paid for is credited back to the student's account (a per-lesson * share of the covering payment) to offset their future scheduled billing. @@ -355,7 +429,7 @@ class BookingEndpoint { return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } - if ( get_current_user_id() !== $lesson->studentId ) { + if ( ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) { return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } diff --git a/src/Booking/BookingPage.php b/src/Booking/BookingPage.php index 8d5e1b2..3000104 100644 --- a/src/Booking/BookingPage.php +++ b/src/Booking/BookingPage.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Auth\RegistrationStatus; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Val; class BookingPage { @@ -18,6 +19,8 @@ class BookingPage { /** The student's upcoming lessons only — nothing bookable. */ public const MODE_UPCOMING = 'upcoming'; + public function __construct( private GuardianService $guardians ) {} + /** * Renders the booking shortcode/block output. * @@ -64,6 +67,11 @@ class BookingPage { $showBooking = self::MODE_UPCOMING !== $mode; $showUpcoming = self::MODE_BOOKING !== $mode; + // Who this account may book for. A single-student account gets one entry + // (themselves) and no selector at all; a guardian's list leads with their + // children, so the default choice is never the parent. + $students = $this->guardians->bookableStudents( get_current_user_id() ); + ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php'; return (string) ob_get_clean(); diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 28f8d8e..372ecc9 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; @@ -20,6 +21,7 @@ class EnrollmentEndpoint { private RegistrationGate $gate, private PaymentService $payments, private GroupAccessRepository $access, + private GuardianService $guardians, ) {} /** @@ -47,6 +49,13 @@ class EnrollmentEndpoint { 'required' => true, 'sanitize_callback' => 'absint', ], + // Who is being enrolled. 0/absent means the caller enrols + // themselves; a child's id is honoured only for their guardian. + 'student_id' => [ + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ], 'answers' => [ 'type' => 'object', 'default' => [], @@ -81,13 +90,25 @@ class EnrollmentEndpoint { } elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) { $enrollments = $this->enrollments->findByInstructor( $userId ); } else { - $enrollments = $this->enrollments->findByStudent( $userId ); + // A guardian sees the whole household's enrolments — their own and + // every child's — so one account covers the family. + $enrollments = []; + foreach ( $this->guardians->householdIds( $userId ) as $studentId ) { + $enrollments = array_merge( $enrollments, $this->enrollments->findByStudent( $studentId ) ); + } } return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 ); } public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + // Who is being enrolled is settled before anything else, so an + // unauthorised student id never reaches a seat claim or a charge. + $studentId = $this->resolveStudent( $request ); + if ( $studentId instanceof \WP_Error ) { + return $studentId; + } + $offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $offering = $this->offerings->findById( $offeringId ); @@ -95,8 +116,6 @@ class EnrollmentEndpoint { return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } - $studentId = get_current_user_id(); - if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) { return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } @@ -133,7 +152,9 @@ class EnrollmentEndpoint { ) ); - $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + // The acceptance binds the student but is attributed to whoever ticked the + // boxes — the guardian, when they enrolled a child. + $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() ); // Mark the access grant used so instructor rosters distinguish invited // students from enrolled ones (a no-op for public classes). @@ -146,7 +167,16 @@ class EnrollmentEndpoint { // regardless of payment. $payment = null; if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) { - $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); + $payment = $this->payments->createForRegistration( + Payment::REG_ENROLLMENT, + $id, + $studentId, + $offering->instructorId, + $offering->price, + $offering->currency, + $offering->etransferEmail, + payerId: $this->guardians->payerFor( $studentId ) + ); } // `payment: null` tells the front end to skip the payment step entirely. @@ -176,7 +206,7 @@ class EnrollmentEndpoint { return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } - if ( get_current_user_id() !== $enrollment->studentId ) { + if ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) { return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } @@ -212,6 +242,30 @@ class EnrollmentEndpoint { return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); } + /** + * Who this enrolment is for: the caller by default, or one of their children + * when a `student_id` is supplied and they are that child's guardian. An id + * the caller may not act for is a 403, never a silent fallback to themselves. + */ + private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error { + $userId = get_current_user_id(); + $requested = absint( Val::int( $request->get_param( 'student_id' ) ) ); + + if ( $requested <= 0 || $requested === $userId ) { + return $userId; + } + + if ( ! $this->guardians->canActFor( $userId, $requested ) ) { + return new \WP_Error( + 'forbidden', + __( 'You cannot enrol that student.', 'unsupervised-schedular' ), + [ 'status' => 403 ] + ); + } + + return $requested; + } + /** * Extract a question_id => value map from the request. * diff --git a/src/GroupClass/GroupClassPage.php b/src/GroupClass/GroupClassPage.php index a6edbc7..cc8f936 100644 --- a/src/GroupClass/GroupClassPage.php +++ b/src/GroupClass/GroupClassPage.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Val; class GroupClassPage { + public function __construct( private GuardianService $guardians ) {} + /** * Renders the group-class enrolment shortcode output. * @@ -39,6 +42,10 @@ class GroupClassPage { $offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) ); + // Who this account may enrol — children first, the account holder last, so + // a guardian's default choice is a child rather than themselves. + $students = $this->guardians->bookableStudents( get_current_user_id() ); + ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php'; return (string) ob_get_clean(); diff --git a/src/Guardian/ChildLoginGate.php b/src/Guardian/ChildLoginGate.php new file mode 100644 index 0000000..58d3509 --- /dev/null +++ b/src/Guardian/ChildLoginGate.php @@ -0,0 +1,62 @@ +ID ) ) { + return new \WP_Error( + 'us_child_account', + esc_html__( 'This is a child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' ) + ); + } + + return $user; + } + + /** + * Strip the booking capability from a child account, so the only route to a + * lesson in their name is their guardian's authorised booking. + * + * @param array $allcaps All capabilities currently held. + * @param array $caps Required capabilities (unused). + * @param array $args Callback args (unused). + * @param mixed $user The user being checked (a WP_User in practice). + * @return array + */ + public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array { + if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) { + unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] ); + } + + return $allcaps; + } +} diff --git a/src/Guardian/FamilyPage.php b/src/Guardian/FamilyPage.php new file mode 100644 index 0000000..a73468f --- /dev/null +++ b/src/Guardian/FamilyPage.php @@ -0,0 +1,284 @@ + $atts Block attributes (`loginPageId`) or + * shortcode attributes (`login_page_id`). + */ + public function render( array $atts ): string { + if ( ! is_user_logged_in() ) { + $loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ); + + return sprintf( + '

%s %s.

', + esc_html__( 'Please', 'unsupervised-schedular' ), + esc_url( $this->loginUrl( $loginPageId ) ), + esc_html__( 'log in to manage your family', 'unsupervised-schedular' ) + ); + } + + wp_enqueue_style( 'us-scheduler' ); + + $userId = get_current_user_id(); + + $children = $this->guardians->children( $userId ); + $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); + $error = $this->submitError; + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked. + $result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) ); + $notice = $this->noticeFor( $result ); + + // Which child the "edit" link opened, if any — the row is swapped for an + // editable form rather than every row carrying one. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked. + $editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) ); + + ob_start(); + include USC_PLUGIN_DIR . 'templates/frontend/family-page.php'; + return (string) ob_get_clean(); + } + + /** + * Process an add/edit/remove submission on `template_redirect`, before any + * page output, then post/redirect/get back to the page. An error is stashed + * for {@see render()} to show inline with the form. + */ + public function maybeHandleSubmit(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below. + $action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) ); + + if ( '' === $action || ! is_user_logged_in() ) { + return; + } + + if ( ! check_admin_referer( 'us_family' ) ) { + return; + } + + $userId = get_current_user_id(); + + $result = match ( $action ) { + 'add' => $this->handleAdd( $userId ), + 'edit' => $this->handleEdit( $userId ), + 'remove' => $this->handleRemove( $userId ), + default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ), + }; + + if ( $result instanceof \WP_Error ) { + $this->submitError = $result->get_error_message(); + return; + } + + $this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) ); + } + + /** + * Add a child, then record their answers to the account-signup questions — + * asked per child, since they describe the student rather than the account. + * + * Required answers are validated *before* the child is created, so a missing + * one never leaves a nameless half-added child behind. + */ + private function handleAdd( int $guardianId ): string|\WP_Error { + $name = $this->postString( 'child_name' ); + $dateOfBirth = $this->postString( 'child_dob' ); + $relationship = $this->postString( 'child_relationship' ); + + $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); + $answers = $this->submittedAnswers(); + + $missing = $this->firstMissingAnswer( $questions, $answers ); + if ( null !== $missing ) { + return $missing; + } + + $childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship ); + if ( $childId instanceof \WP_Error ) { + return $childId; + } + + $this->recordAnswers( $questions, $answers, $childId ); + + return self::RESULT_ADDED; + } + + private function handleEdit( int $guardianId ): string|\WP_Error { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. + $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); + + $result = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); + + return $result instanceof \WP_Error ? $result : self::RESULT_UPDATED; + } + + private function handleRemove( int $guardianId ): string|\WP_Error { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. + $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); + + $result = $this->guardians->removeChild( $guardianId, $childId ); + + return $result instanceof \WP_Error ? $result : self::RESULT_REMOVED; + } + + /** + * The first required question left unanswered, as the error to show — or null + * when every required question has a value. + * + * @param list $questions + * @param array $answers question_id => submitted value + */ + private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error { + foreach ( $questions as $question ) { + if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) { + return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) ); + } + } + + return null; + } + + /** + * Persist a child's answers to the account-signup questions. The answer is + * recorded against the child, not the guardian, so a studio admin reading a + * child's screen sees the information that describes them. + * + * @param list $questions + * @param array $answers question_id => submitted value + */ + private function recordAnswers( array $questions, array $answers, int $childId ): void { + foreach ( $questions as $question ) { + $value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ); + if ( '' === $value ) { + continue; + } + + $this->answers->insert( + new Answer( + questionId: (int) $question->id, + registrationType: Answer::REG_ACCOUNT, + registrationId: $childId, + studentId: $childId, + answerValue: $value, + ) + ); + } + } + + /** + * The account-question answers submitted with the form, keyed by question id. + * + * @return array + */ + private function submittedAnswers(): array { + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; each value is unslashed and sanitized in the loop below. + $raw = $_POST['us_answers'] ?? []; + if ( ! is_array( $raw ) ) { + return []; + } + + $out = []; + foreach ( $raw as $questionId => $value ) { + $out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); + } + + return $out; + } + + /** + * A sanitized text field from the submission. The caller has already verified + * the nonce. + */ + private function postString( string $key ): string { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. + return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) ); + } + + /** + * The confirmation to show for a completed action, or an empty string when + * the flag is absent or unrecognised. + */ + private function noticeFor( string $result ): string { + return match ( $result ) { + self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ), + self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ), + self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ), + default => '', + }; + } + + /** + * The current page's clean permalink, used as the post/redirect/get target so + * the edit flag and any stale notice are dropped from the URL. + */ + private function currentUrl(): string { + $url = get_permalink(); + + return is_string( $url ) ? $url : home_url( '/' ); + } + + /** + * Issues the post-submit redirect and stops the request. Split out so tests + * can observe the target without the process exiting. + */ + protected function redirect( string $url ): void { + wp_safe_redirect( $url ); + exit; + } + + /** + * URL the logged-out prompt sends visitors to: the chosen login page when one + * is configured (and still exists), otherwise the WordPress login screen with + * a redirect back to the current page. + */ + public function loginUrl( int $loginPageId ): string { + if ( $loginPageId > 0 ) { + $url = get_permalink( $loginPageId ); + + if ( is_string( $url ) ) { + return $url; + } + } + + $permalink = get_permalink(); + + return wp_login_url( false === $permalink ? '' : $permalink ); + } +} diff --git a/src/Guardian/GuardianLink.php b/src/Guardian/GuardianLink.php new file mode 100644 index 0000000..b3755f5 --- /dev/null +++ b/src/Guardian/GuardianLink.php @@ -0,0 +1,47 @@ +guardian_id ), + studentId: Val::int( $row->student_id ), + relationship: Val::string( $row->relationship ?? '' ), + createdAt: Val::stringOrNull( $row->created_at ?? null ), + id: Val::int( $row->id ), + ); + } + + /** + * Returns a plain array representation of the link. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'guardian_id' => $this->guardianId, + 'student_id' => $this->studentId, + 'relationship' => $this->relationship, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Guardian/GuardianRepository.php b/src/Guardian/GuardianRepository.php new file mode 100644 index 0000000..376fd64 --- /dev/null +++ b/src/Guardian/GuardianRepository.php @@ -0,0 +1,122 @@ +table = $db->prefix . 'us_guardians'; + } + + /** + * Link a child to a guardian. Returns 0 without inserting when the child + * already has a guardian: v1 is one guardian per child, and the check lives + * here so every caller (signup, the family screen, admin) gets it. + */ + public function insert( GuardianLink $link ): int { + if ( null !== $this->findByStudent( $link->studentId ) ) { + return 0; + } + + $this->db->insert( + $this->table, + [ + 'guardian_id' => $link->guardianId, + 'student_id' => $link->studentId, + 'relationship' => $link->relationship, + 'created_at' => current_time( 'mysql' ), + ], + [ '%d', '%d', '%s', '%s' ] + ); + + return $this->db->insert_id; + } + + /** + * The link naming this child's guardian, or null when they book for + * themselves. + */ + public function findByStudent( int $studentId ): ?GuardianLink { + $row = $this->db->get_row( + $this->db->prepare( + 'SELECT * FROM %i WHERE student_id = %d LIMIT 1', + $this->table, + $studentId + ) + ); + + return $row ? GuardianLink::fromRow( $row ) : null; + } + + /** + * Every child linked to a guardian, oldest link first — the order they are + * offered in the booking selector, so it stays stable as children are added. + * + * @return list + */ + public function findByGuardian( int $guardianId ): array { + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC', + $this->table, + $guardianId + ) + ); + + return array_map( GuardianLink::fromRow( ... ), $rows ?? [] ); + } + + /** + * Whether this exact guardian↔child pair is linked — the authorisation check + * behind every "act for this student" boundary. + */ + public function isGuardianOf( int $guardianId, int $studentId ): bool { + $found = $this->db->get_var( + $this->db->prepare( + 'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1', + $this->table, + $guardianId, + $studentId + ) + ); + + return null !== $found; + } + + /** + * Remove the link between a guardian and one of their children. Deleting the + * child user itself is the caller's decision ({@see GuardianService::removeChild()}); + * this only unlinks. + */ + public function delete( int $guardianId, int $studentId ): bool { + $deleted = $this->db->delete( + $this->table, + [ + 'guardian_id' => $guardianId, + 'student_id' => $studentId, + ], + [ '%d', '%d' ] + ); + + return (int) $deleted > 0; + } + + /** + * How many children a guardian has — enough to decide whether the booking + * page needs a "who is this for?" selector at all. + */ + public function countChildren( int $guardianId ): int { + $count = $this->db->get_var( + $this->db->prepare( + 'SELECT COUNT(*) FROM %i WHERE guardian_id = %d', + $this->table, + $guardianId + ) + ); + + return (int) $count; + } +} diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php new file mode 100644 index 0000000..52a6cb7 --- /dev/null +++ b/src/Guardian/GuardianService.php @@ -0,0 +1,358 @@ +childEmail(); + $userId = wp_insert_user( + [ + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => wp_generate_password( 24, true, true ), + 'display_name' => $name, + 'nickname' => $name, + 'role' => RoleManager::STUDENT, + ] + ); + + if ( is_wp_error( $userId ) ) { + return $userId; + } + + $userId = (int) $userId; + + update_user_meta( $userId, self::META_CHILD, '1' ); + $this->setDateOfBirth( $userId, $dateOfBirth ); + + $linkId = $this->guardians->insert( + new GuardianLink( + guardianId: $guardianId, + studentId: $userId, + relationship: trim( $relationship ), + ) + ); + + // The child was just created, so it cannot already be linked — a failure + // here means the insert itself failed, and leaving an unreachable orphan + // user behind would be worse than reporting it. + if ( $linkId <= 0 ) { + $this->deleteUser( $userId ); + + return new \WP_Error( 'link_failed', __( 'Could not add this child. Please contact the studio.', 'unsupervised-schedular' ) ); + } + + return $userId; + } + + /** + * Rename a child and update their date of birth. Refuses a student the caller + * is not the guardian of, so the family screen cannot be turned into an + * arbitrary user editor by posting someone else's id. + */ + public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): true|\WP_Error { + if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { + return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); + } + + $name = trim( $name ); + if ( '' === $name ) { + return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) ); + } + + $result = wp_update_user( + [ + 'ID' => $studentId, + 'display_name' => $name, + 'nickname' => $name, + ] + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $this->setDateOfBirth( $studentId, $dateOfBirth ); + + return true; + } + + /** + * Unlink a child and delete their account. Refused once the child has any + * lesson or enrolment history: their id is referenced by lessons, payments and + * credits, and deleting the user would orphan all of it. A studio admin + * handles those cases by hand. + */ + public function removeChild( int $guardianId, int $studentId ): true|\WP_Error { + if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { + return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); + } + + if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) { + return new \WP_Error( + 'has_history', + __( 'This child has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' ) + ); + } + + $this->guardians->delete( $guardianId, $studentId ); + $this->deleteUser( $studentId ); + + return true; + } + + /** + * Whether `$actorId` may book, cancel and pay as `$studentId` — true for + * themselves, and for a guardian acting as one of their own children. This is + * the authorisation boundary the REST endpoints and form handlers check before + * honouring a submitted student id. + */ + public function canActFor( int $actorId, int $studentId ): bool { + if ( $actorId <= 0 || $studentId <= 0 ) { + return false; + } + + return $actorId === $studentId || $this->guardians->isGuardianOf( $actorId, $studentId ); + } + + /** + * Who owes a student's charges: their guardian when they have one, otherwise + * themselves. Payments, credits and the billing-method override all resolve + * through this, so a family shares one balance and one billing setting. + */ + public function payerFor( int $studentId ): int { + $link = $this->guardians->findByStudent( $studentId ); + + return null !== $link ? $link->guardianId : $studentId; + } + + /** + * The student ids whose lessons `$userId` may see: their own plus every child + * they are guardian for. + * + * @return list + */ + public function householdIds( int $userId ): array { + $ids = [ $userId ]; + + foreach ( $this->guardians->findByGuardian( $userId ) as $link ) { + $ids[] = $link->studentId; + } + + return array_values( array_unique( $ids ) ); + } + + /** + * The people a user may book or enrol for: **children first**, then + * themselves. The order is the point — a guardian's normal case is booking for + * a child, so the first option (and hence the default selection) is a child, + * never the parent. Booking for a child by mistake 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 from the same account. + * + * @return list + */ + public function bookableStudents( int $userId ): array { + $out = []; + + foreach ( $this->children( $userId ) as $child ) { + $out[] = [ + 'id' => $child['id'], + 'name' => $child['name'], + 'is_self' => false, + ]; + } + + $self = get_userdata( $userId ); + + $out[] = [ + 'id' => $userId, + 'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ), + 'is_self' => true, + ]; + + return $out; + } + + /** + * A guardian's children, in link order, with the details the family and admin + * screens display. + * + * @return list + */ + public function children( int $guardianId ): array { + $out = []; + + foreach ( $this->guardians->findByGuardian( $guardianId ) as $link ) { + $user = get_userdata( $link->studentId ); + + $out[] = [ + 'id' => $link->studentId, + 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ), + 'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ), + 'relationship' => $link->relationship, + ]; + } + + return $out; + } + + /** + * The guardian behind a child, or null when the student books for themselves. + * + * @return array{id: int, name: string, email: string}|null + */ + public function guardianOf( int $studentId ): ?array { + $link = $this->guardians->findByStudent( $studentId ); + if ( null === $link ) { + return null; + } + + $user = get_userdata( $link->guardianId ); + + return [ + 'id' => $link->guardianId, + 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->guardianId ), + 'email' => $user instanceof \WP_User ? $user->user_email : '', + ]; + } + + /** + * Who to contact about a student: their guardian when they have one, otherwise + * the student. What an instructor looking at a child's lesson actually needs — + * a child's own address is an undeliverable placeholder. + * + * @return array{id: int, name: string, email: string} + */ + public function contactFor( int $studentId ): array { + $guardian = $this->guardianOf( $studentId ); + if ( null !== $guardian ) { + return $guardian; + } + + $user = get_userdata( $studentId ); + + return [ + 'id' => $studentId, + 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $studentId ), + 'email' => $user instanceof \WP_User ? $user->user_email : '', + ]; + } + + /** + * A student's display name, or an empty string when the user is gone. Used + * wherever a charge or lesson has to say whose it is. + */ + public function studentName( int $studentId ): string { + $user = get_userdata( $studentId ); + + return UserName::format( $user instanceof \WP_User ? $user : null ); + } + + /** + * Whether a user is a child account (created by a guardian, cannot sign in). + */ + public static function isChild( int $userId ): bool { + return '1' === Val::string( get_user_meta( $userId, self::META_CHILD, true ) ); + } + + /** + * Delete a child's user account. Split out so the front-end paths pull in the + * admin user functions `wp_delete_user()` lives in — it is not loaded on the + * front end, where the family screen runs. + */ + public function deleteUser( int $userId ): void { + if ( ! function_exists( 'wp_delete_user' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + + wp_delete_user( $userId ); + } + + /** + * Store a child's date of birth, or clear it when blank or unparseable. Kept + * as `Y-m-d` so it sorts and displays consistently wherever it is read. + */ + private function setDateOfBirth( int $userId, string $dateOfBirth ): void { + $dateOfBirth = trim( $dateOfBirth ); + + if ( '' === $dateOfBirth ) { + delete_user_meta( $userId, self::META_DOB ); + return; + } + + $parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth ); + if ( false === $parsed ) { + delete_user_meta( $userId, self::META_DOB ); + return; + } + + update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) ); + } + + /** + * An unused placeholder address for a child's account. WordPress requires a + * unique email per user, so the random suffix is retried against + * `email_exists()` rather than assumed unique. + */ + private function childEmail(): string { + do { + $email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN; + } while ( false !== email_exists( $email ) ); + + return strtolower( $email ); + } +} diff --git a/src/Installer.php b/src/Installer.php index 6129913..c76cc19 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -5,7 +5,10 @@ namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; +use Unsupervised\Schedular\Payment\CreditRepository; +use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\ScheduledBillingRunner; +use Unsupervised\Schedular\Policy\AcceptanceRepository; class Installer { @@ -50,5 +53,13 @@ class Installer { } ( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows(); + + // Guardian accounts introduced "who pays" / "who agreed" alongside "who the + // student is". Every row written before then had them one and the same, so + // point the new columns at the student rather than leaving them 0 — the + // balance and acceptance lookups key on them directly. + ( new PaymentRepository( $wpdb ) )->backfillPayerIds(); + ( new CreditRepository( $wpdb ) )->backfillPayerIds(); + ( new AcceptanceRepository( $wpdb ) )->backfillAcceptedBy(); } } diff --git a/src/Payment/Credit.php b/src/Payment/Credit.php index f2dfc5e..7132a1e 100644 --- a/src/Payment/Credit.php +++ b/src/Payment/Credit.php @@ -26,6 +26,12 @@ class Credit { public readonly int $studentId, public readonly float $amount, public readonly float $remaining, + /** + * The account holding this balance — a child's guardian, or 0 meaning + * "the student themselves". A family's credits all sit on the guardian, + * so one child's cancellation can settle a sibling's charge. + */ + public readonly int $payerId = 0, public readonly string $currency = 'CAD', public readonly ?int $sourcePaymentId = null, public readonly ?int $sourceLessonId = null, @@ -41,6 +47,7 @@ class Credit { studentId: Val::int( $row->student_id ), amount: Val::float( $row->amount ), remaining: Val::float( $row->remaining ), + payerId: Val::int( $row->payer_id ?? 0 ), currency: Val::string( $row->currency ), sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ), sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ), @@ -56,6 +63,15 @@ class Credit { return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0; } + /** + * Whose balance this credit sits in: the recorded payer, falling back to the + * student. Callers go through here rather than reading `payerId`, so the `0` + * default of a pre-guardian credit never leaks out as a user id. + */ + public function payerOrStudent(): int { + return $this->payerId > 0 ? $this->payerId : $this->studentId; + } + /** * Returns a plain array representation of the credit. * @@ -65,6 +81,7 @@ class Credit { return [ 'id' => $this->id, 'student_id' => $this->studentId, + 'payer_id' => $this->payerOrStudent(), 'amount' => $this->amount, 'remaining' => $this->remaining, 'currency' => $this->currency, diff --git a/src/Payment/CreditRepository.php b/src/Payment/CreditRepository.php index 5ca279e..0a2c739 100644 --- a/src/Payment/CreditRepository.php +++ b/src/Payment/CreditRepository.php @@ -16,6 +16,7 @@ class CreditRepository { $this->table, [ 'student_id' => $credit->studentId, + 'payer_id' => $credit->payerOrStudent(), 'amount' => $credit->amount, 'remaining' => $credit->remaining, 'currency' => $credit->currency, @@ -25,7 +26,7 @@ class CreditRepository { 'status' => $credit->status, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ] + [ '%d', '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ] ); return $this->db->insert_id; @@ -56,15 +57,16 @@ class CreditRepository { } /** - * A student's total unused credit balance (sum of the remaining amounts of every - * still-available credit). + * A payer's total unused credit balance (sum of the remaining amounts of every + * still-available credit). Keyed on the payer, so a guardian's balance covers + * credits earned by any of their children — one family, one balance. */ - public function availableBalance( int $studentId ): float { + public function availableBalance( int $payerId ): float { $total = $this->db->get_var( $this->db->prepare( - 'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s', + 'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE payer_id = %d AND status = %s', $this->table, - $studentId, + $payerId, Credit::STATUS_AVAILABLE ) ); @@ -73,17 +75,17 @@ class CreditRepository { } /** - * A student's still-available credits, oldest first — the FIFO order they are + * A payer's still-available credits, oldest first — the FIFO order they are * consumed in. * * @return list */ - public function findAvailableByStudent( int $studentId ): array { + public function findAvailableByPayer( int $payerId ): array { $rows = $this->db->get_results( $this->db->prepare( - 'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC', + 'SELECT * FROM %i WHERE payer_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC', $this->table, - $studentId, + $payerId, Credit::STATUS_AVAILABLE ) ); @@ -92,7 +94,10 @@ class CreditRepository { } /** - * Every credit for a student, newest first (admin history). + * Every credit earned by a student, newest first — the admin history on their + * own screen. Unlike the balance this is keyed on the student, so a child's + * screen shows the credits their cancellations produced even though the + * balance itself sits with their guardian. * * @return list */ @@ -109,17 +114,30 @@ class CreditRepository { } /** - * Draw down a student's credit balance by $amount, consuming their available + * Backfill `payer_id` on credits written before guardian accounts existed, + * where the student was always the payer. Run once from the installer so the + * payer-keyed balance queries see those rows. + */ + public function backfillPayerIds(): void { + $sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table ); + + if ( null !== $sql ) { + $this->db->query( $sql ); + } + } + + /** + * Draw down a payer's credit balance by $amount, consuming their available * credits oldest first and marking each fully-spent credit `consumed`. Stops once * the amount is exhausted; a balance shorter than $amount simply drains to zero. */ - public function consume( int $studentId, float $amount ): void { + public function consume( int $payerId, float $amount ): void { $remaining = round( $amount, 2 ); if ( $remaining <= 0.0 ) { return; } - foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) { + foreach ( $this->findAvailableByPayer( $payerId ) as $credit ) { if ( $remaining <= 0.0 ) { break; } diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index 230b5f0..bd35870 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -39,6 +39,13 @@ class Payment { public readonly string $registrationType, public readonly int $registrationId, public readonly float $amount, + /** + * The account that owes this charge — a child's guardian, or 0 meaning + * "the student themselves". Zero rather than a copy of `studentId` so + * every payment written before guardian accounts existed reads back with + * its original meaning without a data migration. + */ + public readonly int $payerId = 0, public readonly string $currency = 'CAD', public readonly string $method = self::METHOD_ETRANSFER, public readonly string $status = self::STATUS_PENDING, @@ -64,6 +71,7 @@ class Payment { registrationType: Val::string( $row->registration_type ), registrationId: Val::int( $row->registration_id ), amount: Val::float( $row->amount ), + payerId: Val::int( $row->payer_id ?? 0 ), currency: Val::string( $row->currency ), method: Val::string( $row->method ), status: Val::string( $row->status ), @@ -87,6 +95,25 @@ class Payment { return self::STATUS_PAID === $this->status; } + /** + * Who actually owes this charge: the recorded payer, falling back to the + * student. Every caller that needs a person to bill, receipt or credit goes + * through here rather than reading `payerId` directly, so the `0` default + * never leaks out as a user id. + */ + public function payerOrStudent(): int { + return $this->payerId > 0 ? $this->payerId : $this->studentId; + } + + /** + * Whether someone other than the student is paying — a guardian. Drives the + * "paid by" line on admin screens, which is noise when they are the same + * person. + */ + public function hasSeparatePayer(): bool { + return $this->payerId > 0 && $this->payerId !== $this->studentId; + } + /** * Whether this payment was generated by the daily billing scan (weekly / * monthly) rather than taken at registration. Scheduled payments carry a due @@ -134,6 +161,7 @@ class Payment { return [ 'id' => $this->id, 'student_id' => $this->studentId, + 'payer_id' => $this->payerOrStudent(), 'instructor_id' => $this->instructorId, 'registration_type' => $this->registrationType, 'etransfer_email' => $this->etransferEmail, diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index f55ef3e..d96c396 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -16,6 +16,7 @@ class PaymentRepository { $this->table, [ 'student_id' => $payment->studentId, + 'payer_id' => $payment->payerOrStudent(), 'instructor_id' => $payment->instructorId, 'registration_type' => $payment->registrationType, 'registration_id' => $payment->registrationId, @@ -36,12 +37,24 @@ class PaymentRepository { 'paid_at' => $payment->paidAt, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] + [ '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $this->db->insert_id; } + /** + * Backfill `payer_id` on payments written before guardian accounts existed, + * where the student was always the payer. Run once from the installer. + */ + public function backfillPayerIds(): void { + $sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table ); + + if ( null !== $sql ) { + $this->db->query( $sql ); + } + } + /** * Attach the Stripe PaymentIntent id created for a card payment so the webhook * can later reconcile the charge back to this row. diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 2dbda70..ac0831b 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -34,14 +34,19 @@ class PaymentService { * A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing * scan (weekly / monthly) rather than taken at registration; both stay null for * the pay-now flow. + * + * `$payerId` is who owes it — a child's guardian, or 0 (the default) when the + * student pays for themselves. The billing method resolves against the payer, + * so comping or card-billing a family is one setting on the guardian. */ - public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment { + public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null, int $payerId = 0 ): ?Payment { if ( $amount <= 0.0 ) { return null; } - $method = $this->resolver->resolve( $studentId ); - $status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING; + $payerId = $payerId > 0 ? $payerId : $studentId; + $method = $this->resolver->resolve( $payerId ); + $status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING; $etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail ? $offeringEtransferEmail @@ -58,6 +63,7 @@ class PaymentService { registrationType: $type, registrationId: $registrationId, amount: $amount, + payerId: $payerId, currency: $currency, method: $method, status: $status, @@ -72,7 +78,9 @@ class PaymentService { $this->linkPayment( $type, $registrationId, $id ); if ( Payment::STATUS_PAID === $status ) { - $this->finalizePaid( $id, $type, $registrationId, $studentId ); + // The receipt goes to whoever paid, which for a child's lesson is the + // guardian — a child's own address is an undeliverable placeholder. + $this->finalizePaid( $id, $type, $registrationId, $payerId ); } return $this->payments->findById( $id ); @@ -111,7 +119,7 @@ class PaymentService { return true; } - $this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId ); + $this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() ); return true; } @@ -174,11 +182,15 @@ class PaymentService { return null; } + // The credit records the child it was earned for, but the balance itself + // lands on whoever paid — so a family's credits pool on the guardian and + // one child's cancellation can settle a sibling's next charge. $id = $this->credits->insert( new Credit( studentId: $payment->studentId, amount: $share, remaining: $share, + payerId: $payment->payerOrStudent(), currency: $payment->currency, sourcePaymentId: $payment->id, sourceLessonId: $lesson->id, @@ -209,19 +221,22 @@ class PaymentService { } /** - * Apply a student's available credit balance against a set of freshly-created + * Apply a payer's available credit balance against a set of freshly-created * pending payments (the ones a billing scan just generated for them), oldest * charge first. Each payment's `credit_applied` is raised by the amount covered; * a payment fully covered is marked paid-by-credit and its registration confirmed * so it leaves the confirmation queue. The credit ledger is drawn down by the * total applied. Returns a map of payment id to the credit applied to it, so the - * caller can reflect the reduction on the student's notice. + * caller can reflect the reduction on the payer's notice. + * + * Keyed on the payer, so a guardian's balance settles charges raised against + * any of their children — the payments passed in may name several students. * * @param list $payments * @return array */ - public function applyCredits( int $studentId, array $payments ): array { - $balance = $this->credits->availableBalance( $studentId ); + public function applyCredits( int $payerId, array $payments ): array { + $balance = $this->credits->availableBalance( $payerId ); if ( $balance <= 0.0 ) { return []; } @@ -258,7 +273,7 @@ class PaymentService { } if ( $consumed > 0.0 ) { - $this->credits->consume( $studentId, $consumed ); + $this->credits->consume( $payerId, $consumed ); } return $applied; @@ -272,11 +287,19 @@ class PaymentService { * needs no further action. Returns null when the registration has no payment, * the caller does not own it, or Stripe could not create the intent. * + * `$userId` is the caller: either the student the registration is for, or the + * guardian who owes it — anyone else gets null rather than a payment step for + * a charge that is not theirs. + * * @return array|null */ - public function createIntent( string $type, int $registrationId, int $studentId ): ?array { + public function createIntent( string $type, int $registrationId, int $userId ): ?array { $payment = $this->payments->findByRegistration( $type, $registrationId ); - if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) { + if ( null === $payment || null === $payment->id ) { + return null; + } + + if ( $payment->studentId !== $userId && $payment->payerOrStudent() !== $userId ) { return null; } @@ -334,7 +357,7 @@ class PaymentService { } if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) { - $this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId ); + $this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() ); } elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) { $this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED ); } @@ -342,12 +365,12 @@ class PaymentService { return true; } - private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void { + private function finalizePaid( int $paymentId, string $type, int $registrationId, int $payerId ): void { $this->payments->markPaid( $paymentId, 'USC-' . $paymentId ); $this->confirmRegistration( $type, $registrationId ); $paid = $this->payments->findById( $paymentId ); - $user = get_userdata( $studentId ); + $user = get_userdata( $payerId ); if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) { $this->payments->markReceiptSent( $paymentId ); } diff --git a/src/Payment/ScheduledBillingRunner.php b/src/Payment/ScheduledBillingRunner.php index 1395970..1d52994 100644 --- a/src/Payment/ScheduledBillingRunner.php +++ b/src/Payment/ScheduledBillingRunner.php @@ -6,13 +6,14 @@ namespace Unsupervised\Schedular\Payment; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Val; /** * Generates the pending payments that scheduled-billing offerings (weekly / - * monthly) owe as they come due, then emails each student one itemised notice. + * monthly) owe as they come due, then emails each payer one itemised notice. * * Runs from the daily WP-Cron action `us_generate_due_payments`. It is * self-healing: every run re-scans from the current ledger state, so a missed @@ -30,6 +31,7 @@ class ScheduledBillingRunner { private EnrollmentRepository $enrollments, private OfferingRepository $offerings, private PaymentDueMailer $mailer, + private GuardianService $guardians, ) {} public function register(): void { @@ -42,12 +44,13 @@ class ScheduledBillingRunner { public function run(): void { $now = $this->now(); - // One notice bucket per student, filled as pending payments are created and - // flushed to a single email at the end, so a student billed for several - // lessons on one day is emailed once — never once per lesson. Each entry keeps - // the created payment and its label; credits are applied across the whole - // bucket before the notice is built, so a student's account credit offsets the - // run's charges oldest-first. + // One notice bucket per *payer*, filled as pending payments are created and + // flushed to a single email at the end, so a payer billed for several + // lessons on one day is emailed once — never once per lesson, and a guardian + // gets one notice covering every child rather than one per child. Each entry + // keeps the created payment and its label; credits are applied across the + // whole bucket before the notice is built, so the family's account credit + // offsets the run's charges oldest-first. $buckets = []; $this->billPrivateLessons( $now, $buckets ); @@ -303,19 +306,24 @@ class ScheduledBillingRunner { /** * Create one scheduled payment and, when it is pending (not a comp auto-pay), - * add it to the student's notice bucket with the label to show on the notice. + * add it to the payer's notice bucket with the label to show on the notice. * Credits are applied later, once the whole bucket is known. Returns the created * payment, or null when there was nothing to charge. * + * The charge is bucketed against whoever owes it, so a guardian's notice covers + * all their children; the label names the child when that differs from the + * payer, or a parent cannot tell whose lesson each line is. + * * @param array> $buckets */ private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment { - $payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey ); + $payerId = $this->guardians->payerFor( $studentId ); + $payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey, $payerId ); if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) { - $buckets[ $studentId ][] = [ + $buckets[ $payerId ][] = [ 'payment' => $payment, - 'label' => $label, + 'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ), ]; } @@ -323,7 +331,17 @@ class ScheduledBillingRunner { } /** - * For each student, apply any account credit they hold against the run's charges, + * Prefix a notice line with the student it is for — "Ada: Piano Lesson — + * Mar 3, 2026" — used only when the payer is not the student. + */ + private function labelFor( int $studentId, string $label ): string { + $name = $this->guardians->studentName( $studentId ); + + return '' === $name ? $label : $name . ': ' . $label; + } + + /** + * For each payer, apply any account credit they hold against the run's charges, * tag the payments they still owe with a shared batch reference, and email them * one itemised notice. The notice lists each charge at its full amount, then the * credit applied and the reduced total due; a charge fully covered by credit is @@ -333,9 +351,9 @@ class ScheduledBillingRunner { * @param array> $buckets */ private function sendNotices( array $buckets ): void { - foreach ( $buckets as $studentId => $entries ) { + foreach ( $buckets as $payerId => $entries ) { $payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries ); - $applied = $this->payments->applyCredits( $studentId, $payments ); + $applied = $this->payments->applyCredits( $payerId, $payments ); $items = []; $batchIds = []; @@ -366,7 +384,7 @@ class ScheduledBillingRunner { $reference = [] !== $batchIds ? $this->reference() : ''; $this->payments->assignNoticeBatch( $batchIds, $reference ); - $user = get_userdata( $studentId ); + $user = get_userdata( $payerId ); if ( $user instanceof \WP_User ) { $this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) ); } diff --git a/src/Plugin.php b/src/Plugin.php index 46b5759..889b966 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -17,6 +17,10 @@ use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\ChildLoginGate; +use Unsupervised\Schedular\Guardian\FamilyPage; +use Unsupervised\Schedular\Guardian\GuardianRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\CreditRepository; @@ -76,6 +80,9 @@ class Plugin { $groupAccess = new GroupAccessRepository( $wpdb ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); + $guardianRepo = new GuardianRepository( $wpdb ); + $guardians = new GuardianService( $guardianRepo, $bookings, $enrollments ); + $paymentRepo = new PaymentRepository( $wpdb ); $creditRepo = new CreditRepository( $wpdb ); $settings = new StudioSettings(); @@ -87,21 +94,23 @@ class Plugin { // front-end output is identical whichever way a page embeds them. $registrationMailer = new RegistrationMailer(); - $bookingPage = new BookingPage(); + $bookingPage = new BookingPage( $guardians ); $loginPage = new LoginPage(); - $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess ); - $groupClassPage = new GroupClassPage(); + $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians ); + $groupClassPage = new GroupClassPage( $guardians ); + $familyPage = new FamilyPage( $guardians, $questions, $answers ); - ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register(); + ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register(); ( new UpdateChecker() )->register(); ( new RoleManager() )->register(); ( new RegistrationLoginGate() )->register(); + ( new ChildLoginGate() )->register(); ( new StudentAdminGuard() )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); - ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register(); - ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register(); - ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); - ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register(); + ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); + ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register(); } } diff --git a/src/Policy/AcceptanceRepository.php b/src/Policy/AcceptanceRepository.php index 4aa0a2e..f5c1d4f 100644 --- a/src/Policy/AcceptanceRepository.php +++ b/src/Policy/AcceptanceRepository.php @@ -17,12 +17,13 @@ class AcceptanceRepository { [ 'policy_version_id' => $acceptance->policyVersionId, 'student_id' => $acceptance->studentId, + 'accepted_by' => $acceptance->acceptorOrStudent(), 'registration_type' => $acceptance->registrationType, 'registration_id' => $acceptance->registrationId, 'ip_address' => $acceptance->ipAddress, 'accepted_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%s', '%d', '%s', '%s' ] + [ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ] ); return $this->db->insert_id; @@ -38,6 +39,19 @@ class AcceptanceRepository { return array_map( fn( PolicyAcceptance $a ): int => $this->insert( $a ), $acceptances ); } + /** + * Backfill `accepted_by` on acceptances recorded before guardian accounts + * existed, where the student always agreed for themselves. Run once from the + * installer so the acceptor is a real user id on every row. + */ + public function backfillAcceptedBy(): void { + $sql = $this->db->prepare( 'UPDATE %i SET accepted_by = student_id WHERE accepted_by = 0', $this->table ); + + if ( null !== $sql ) { + $this->db->query( $sql ); + } + } + /** * Find all acceptances attached to a registration (lesson or enrolment). * diff --git a/src/Policy/PolicyAcceptance.php b/src/Policy/PolicyAcceptance.php index c5ceb3f..f364ff8 100644 --- a/src/Policy/PolicyAcceptance.php +++ b/src/Policy/PolicyAcceptance.php @@ -24,6 +24,13 @@ class PolicyAcceptance { public readonly int $studentId, public readonly string $registrationType, public readonly int $registrationId, + /** + * Who actually clicked "I agree" — a child's guardian, or 0 meaning the + * student agreed for themselves. Zero rather than a copy of `studentId` + * so every acceptance recorded before guardian accounts existed keeps its + * original meaning. + */ + public readonly int $acceptedBy = 0, public readonly ?string $ipAddress = null, public readonly ?string $acceptedAt = null, public readonly ?int $id = null, @@ -35,12 +42,31 @@ class PolicyAcceptance { studentId: Val::int( $row->student_id ), registrationType: Val::string( $row->registration_type ), registrationId: Val::int( $row->registration_id ), + acceptedBy: Val::int( $row->accepted_by ?? 0 ), ipAddress: Val::stringOrNull( $row->ip_address ), acceptedAt: Val::stringOrNull( $row->accepted_at ), id: Val::int( $row->id ), ); } + /** + * Who this acceptance is legally attributable to: the recorded acceptor, + * falling back to the student. Callers go through here so the `0` default of a + * pre-guardian acceptance never leaks out as a user id. + */ + public function acceptorOrStudent(): int { + return $this->acceptedBy > 0 ? $this->acceptedBy : $this->studentId; + } + + /** + * Whether someone other than the student agreed — a guardian accepting on a + * child's behalf. Drives the "accepted by" line on the admin screen, which is + * noise when they are the same person. + */ + public function acceptedOnBehalf(): bool { + return $this->acceptedBy > 0 && $this->acceptedBy !== $this->studentId; + } + /** * Returns a plain array representation of the acceptance. * @@ -51,6 +77,7 @@ class PolicyAcceptance { 'id' => $this->id, 'policy_version_id' => $this->policyVersionId, 'student_id' => $this->studentId, + 'accepted_by' => $this->acceptorOrStudent(), 'registration_type' => $this->registrationType, 'registration_id' => $this->registrationId, 'ip_address' => $this->ipAddress, diff --git a/src/Registration/QuestionField.php b/src/Registration/QuestionField.php new file mode 100644 index 0000000..4f8a777 --- /dev/null +++ b/src/Registration/QuestionField.php @@ -0,0 +1,61 @@ +isRequired && $enforceRequired ? ' required' : ''; + + $label = ''; + + return '

' . $label . self::input( $question, $name, $id, $required ) . '

'; + } + + /** + * The input element itself, chosen by the question's field type. `$required` + * is a literal attribute string (' required' or ''), not user input. + */ + private static function input( Question $question, string $name, string $id, string $required ): string { + $common = ' name="' . esc_attr( $name ) . '" id="' . esc_attr( $id ) . '"' . $required; + + if ( Question::FIELD_TEXTAREA === $question->fieldType ) { + return ''; + } + + if ( Question::FIELD_SELECT === $question->fieldType ) { + $options = ''; + foreach ( (array) $question->options as $option ) { + $options .= ''; + } + + return '' . $options . ''; + } + + if ( Question::FIELD_CHECKBOX === $question->fieldType ) { + return ''; + } + + return ''; + } +} diff --git a/src/Registration/RegistrationGate.php b/src/Registration/RegistrationGate.php index f40242c..3acf913 100644 --- a/src/Registration/RegistrationGate.php +++ b/src/Registration/RegistrationGate.php @@ -58,10 +58,14 @@ class RegistrationGate { /** * Persist answers and policy acceptances for a created registration. * + * `$acceptedBy` is who actually agreed, when that is not the student — a + * guardian booking for a child. It defaults to 0, read back as "the student + * agreed for themselves". + * * @param array $answers question_id => answer value * @param list $acceptedVersionIds Accepted policy version IDs */ - public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null ): void { + public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void { foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) { $value = (string) ( $answers[ (int) $question->id ] ?? '' ); if ( '' === $value ) { @@ -90,6 +94,7 @@ class RegistrationGate { studentId: $studentId, registrationType: $registrationType, registrationId: $registrationId, + acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId, ipAddress: $ipAddress, ) ); diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 38a84cc..00ae324 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -12,6 +12,7 @@ use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\PaymentEndpoint; @@ -37,13 +38,13 @@ class RestRegistrar { private EnrollmentEndpoint $enrollmentEndpoint; private PaymentEndpoint $paymentEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) ); - $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) ); + $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); - $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess ); + $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians ); $this->paymentEndpoint = new PaymentEndpoint( $paymentService ); } diff --git a/src/Schema.php b/src/Schema.php index 17ea146..57390d3 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -138,6 +138,7 @@ class Schema { id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, policy_version_id BIGINT UNSIGNED NOT NULL, student_id BIGINT UNSIGNED NOT NULL, + accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0, registration_type VARCHAR(20) NOT NULL, registration_id BIGINT UNSIGNED NOT NULL, accepted_at DATETIME NOT NULL, @@ -145,12 +146,14 @@ class Schema { PRIMARY KEY (id), KEY policy_version_id (policy_version_id), KEY student_id (student_id), + KEY accepted_by (accepted_by), KEY registration (registration_type, registration_id) ) {$charset};", "CREATE TABLE {$prefix}us_payments ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, student_id BIGINT UNSIGNED NOT NULL, + payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0, instructor_id BIGINT UNSIGNED NOT NULL, registration_type VARCHAR(20) NOT NULL, registration_id BIGINT UNSIGNED NOT NULL, @@ -172,6 +175,7 @@ class Schema { paid_at DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY student_id (student_id), + KEY payer_id (payer_id), KEY instructor_id (instructor_id), KEY registration (registration_type, registration_id), KEY status (status) @@ -180,6 +184,7 @@ class Schema { "CREATE TABLE {$prefix}us_credits ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, student_id BIGINT UNSIGNED NOT NULL, + payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL DEFAULT 0, remaining DECIMAL(10,2) NOT NULL DEFAULT 0, currency VARCHAR(3) NOT NULL DEFAULT 'CAD', @@ -191,6 +196,7 @@ class Schema { updated_at DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY student_id (student_id), + KEY payer_id (payer_id), KEY status (status), KEY source_lesson_id (source_lesson_id) ) {$charset};", @@ -229,6 +235,21 @@ class Schema { KEY status (status) ) {$charset};", + // Links a parent/guardian account to a child who books through it. The + // child is a real (login-less) wp_users row, so student_id keeps meaning + // "a WordPress user" on every other table. + "CREATE TABLE {$prefix}us_guardians ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + guardian_id BIGINT UNSIGNED NOT NULL, + student_id BIGINT UNSIGNED NOT NULL, + relationship VARCHAR(50) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY guardian_student (guardian_id, student_id), + KEY guardian_id (guardian_id), + KEY student_id (student_id) + ) {$charset};", + "CREATE TABLE {$prefix}us_group_access ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, offering_id BIGINT UNSIGNED NOT NULL, diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index d35fac3..919ee30 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\FamilyPage; use Unsupervised\Schedular\Payment\StudioSettings; class ShortcodeRegistrar { @@ -16,6 +17,7 @@ class ShortcodeRegistrar { private LoginPage $loginPage, private RegistrationPage $registrationPage, private GroupClassPage $groupClassPage, + private FamilyPage $familyPage, ) {} public function register(): void { @@ -23,10 +25,14 @@ class ShortcodeRegistrar { add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) ); add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) ); add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) ); + add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) ); // Process registration submissions before output so the invite branch's // auth cookie is actually sent (render() runs too late, during the_content). add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] ); add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] ); + // Same reason as registration: the family form redirects after handling, + // which render() (running during the_content) is too late to do. + add_action( 'template_redirect', [ $this->familyPage, 'maybeHandleSubmit' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] ); } @@ -76,8 +82,11 @@ class ShortcodeRegistrar { // Price formatting and the pay agreement, shared by booking and enrolment. wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], USC_VERSION, true ); - wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing' ], USC_VERSION, true ); - wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing' ], USC_VERSION, true ); + // The "who is this for?" picker, shared by booking and enrolment. + wp_register_script( 'us-scheduler-guardian', USC_PLUGIN_URL . 'assets/js/guardian.js', [], USC_VERSION, true ); + + wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true ); + wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true ); // Progressive enhancement for the two-step registration form (no dependencies). wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true ); diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index ae3aa86..dd65952 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -15,8 +15,12 @@ if (! defined('ABSPATH')) { * @var list $intake * @var list $payments * @var list $credits - * @var float $creditBalance + * @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child. * @var string $creditCurrency + * @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves. + * @var list $children Children this student books for. + * @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian. + * @var string $pageSlug * @var string $backUrl * @var bool $canBilling * @var string $billingOverride @@ -105,6 +109,37 @@ $renderLessons = static function (array $rows, bool $withActions = false): void + +

+ add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?> + +

+ ' . esc_html($guardian['name']) . '', + esc_html($guardian['email']) + ); + ?> +

+

+ + +

+
    + +
  • + + + + +
  • + +
+ + +

@@ -253,6 +288,17 @@ $renderLessons = static function (array $rows, bool $withActions = false): void '' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '' ); ?> + ID) : ?> + + + +

diff --git a/templates/admin/students.php b/templates/admin/students.php index 7b403dc..c9e4cfe 100644 --- a/templates/admin/students.php +++ b/templates/admin/students.php @@ -6,9 +6,39 @@ if (! defined('ABSPATH')) { } /** - * @var list $students + * @var list}> $students * @var string $pageSlug */ + +/** + * The Family cell: for a child, the guardian who books for them; for a guardian, + * the children they book for. Both link to the other person's detail screen so + * an admin can move between a family without going back to the list. + */ +$familyCell = static function (array $student) use ($pageSlug): string { + $link = static fn(int $id, string $name): string => sprintf( + '%s', + esc_url(add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php'))), + esc_html($name) + ); + + if ($student['guardian'] !== null) { + return sprintf( + /* translators: %s: linked name of the parent/guardian who books for this student. */ + esc_html__('Child of %s', 'unsupervised-schedular'), + $link($student['guardian']['id'], $student['guardian']['name']) + ); + } + + if ($student['children'] === []) { + return '—'; + } + + return implode(', ', array_map( + static fn(array $child): string => $link($child['id'], $child['name']), + $student['children'] + )); +}; ?>

@@ -21,6 +51,7 @@ if (! defined('ABSPATH')) { + @@ -32,6 +63,12 @@ if (! defined('ABSPATH')) { + + + diff --git a/templates/frontend/booking-page.php b/templates/frontend/booking-page.php index c787111..912b33b 100644 --- a/templates/frontend/booking-page.php +++ b/templates/frontend/booking-page.php @@ -9,8 +9,14 @@ if (! defined('ABSPATH')) { /** @var bool $showTypeFilter Whether the "Show Only" lesson-type filter is offered. */ /** @var bool $showBooking Whether the booking calendar is part of this embed. */ /** @var bool $showUpcoming Whether the student's upcoming-lessons panel is part of this embed. */ +/** @var list $students Who this account may book for — children first, the account holder last. */ + +// The booking script reads the list as JSON rather than rendering a + +

+ + +

+

+ + +

+

+ + +

+ + + + + + + + +
+ + + + +
+
+ + + + + + +
+ + + +

+

+ + +

+

+ + +

+

+ + +

+ + +
+ + + id . ']', 'us-family-q-' . (int) $question->id); + ?> + +
+ + +

+ +

+
+
diff --git a/templates/frontend/group-classes-page.php b/templates/frontend/group-classes-page.php index 6c3c2bf..90cede9 100644 --- a/templates/frontend/group-classes-page.php +++ b/templates/frontend/group-classes-page.php @@ -6,8 +6,13 @@ if (! defined('ABSPATH')) { } /** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */ +/** @var list $students Who this account may enrol — children first, the account holder last. */ + +// The enrolment script reads the list as JSON, so the picker can be built inside +// the enrolment form it renders. +$studentsJson = wp_json_encode(array_values($students)); ?> -
0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>> +
0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>

diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 5ba1376..2db52c0 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -2,6 +2,7 @@ declare(strict_types=1); use Unsupervised\Schedular\Registration\Question; +use Unsupervised\Schedular\Registration\QuestionField; if (! defined('ABSPATH')) { exit; @@ -22,39 +23,6 @@ if (! defined('ABSPATH')) { * @var list $accountQuestions Studio-wide questions answered as step two. */ -/** - * Render one account-signup question's input, named `us_answers[]`. - */ -$renderQuestionField = static function (Question $question): void { - $id = (int) $question->id; - $name = 'us_answers[' . $id . ']'; - $fieldId = 'us-reg-q-' . $id; - $required = $question->isRequired ? ' required' : ''; - ?> -

- - fieldType === Question::FIELD_TEXTAREA) : ?> - - fieldType === Question::FIELD_SELECT) : ?> - - fieldType === Question::FIELD_CHECKBOX) : ?> - > - - > - -

-
@@ -102,6 +70,48 @@ $renderQuestionField = static function (Question $question): void {

+
+ +

+ +

+ + +
+

+ + +
+

+ + +

+

+ + +

+ + id . ']', + 'us-child-0-q-' . (int) $question->id, + enforceRequired: false + ); + ?> + +
+ +

+ +

+
+
+
@@ -124,6 +134,17 @@ $renderQuestionField = static function (Question $question): void {

+ +

@@ -137,7 +158,10 @@ $renderQuestionField = static function (Question $question): void {

- + id . ']', 'us-reg-q-' . (int) $question->id); + ?>

diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index 68e84ff..1d1b1a8 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -10,9 +10,11 @@ use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\Policy; +use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyVersion; use Unsupervised\Schedular\Policy\PolicyVersionRepository; @@ -63,6 +65,7 @@ class RegistrationPageTest extends TestCase $this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class); $this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class); + $this->ctx['guardians'] = Mockery::mock(GuardianService::class); $this->ctx['page'] = new RegistrationPage( $invites, @@ -74,6 +77,7 @@ class RegistrationPageTest extends TestCase $questions, $answers, $access, + $this->ctx['guardians'], ); $_POST = []; @@ -444,6 +448,7 @@ class RegistrationPageTest extends TestCase $this->ctx['questions'], $this->ctx['answers'], $this->ctx['access'], + $this->ctx['guardians'], ] )->makePartial()->shouldAllowMockingProtectedMethods(); @@ -600,4 +605,192 @@ class RegistrationPageTest extends TestCase self::assertStringContainsString('Ask the front desk for a link.', $html); self::assertStringNotContainsString('by invitation only', $html); } + + /** + * A guardian's signup creates one login-less child per filled block, links + * them, and records each child's answers against the child rather than the + * account holder — the questions describe the student, not the parent. + */ + public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Grace', + 'us_is_guardian' => '1', + 'children' => [ + ['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']], + ['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']], + // An untouched spare block is dropped, not rejected. + ['name' => ' ', 'dob' => '', 'answers' => []], + ], + ]; + + $this->ctx['questions']->shouldReceive('findByScope')->andReturn([ + new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7), + ]); + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); + + $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101); + $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102); + + $recorded = []; + $this->ctx['answers']->shouldReceive('insert')->andReturnUsing( + static function (Answer $a) use (&$recorded): int { + $recorded[] = [$a->studentId, $a->answerValue]; + return 1; + } + ); + + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + Functions\expect('wp_set_current_user')->once()->with(42); + Functions\expect('wp_set_auth_cookie')->once()->with(42); + + self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false)); + self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded); + } + + public function testGuardianSignupWithNoChildrenIsRejected(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Grace', + 'us_is_guardian' => '1', + 'children' => [['name' => '', 'dob' => '', 'answers' => []]], + ]; + + Functions\when('email_exists')->justReturn(false); + Functions\expect('wp_insert_user')->never(); + $this->ctx['guardians']->shouldNotReceive('createChild'); + + $result = $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false); + + self::assertStringContainsString('at least one child', $result); + } + + /** + * Required per-child answers are validated before any user exists, so a + * missing one never leaves a half-registered family behind. + */ + public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Grace', + 'us_is_guardian' => '1', + 'children' => [ + ['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']], + ['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']], + ], + ]; + + $this->ctx['questions']->shouldReceive('findByScope')->andReturn([ + new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7), + ]); + + Functions\when('email_exists')->justReturn(false); + Functions\expect('wp_insert_user')->never(); + $this->ctx['guardians']->shouldNotReceive('createChild'); + + self::assertStringContainsString('for each child', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false)); + } + + /** + * A family that half-created would leave the guardian unable to re-register + * and their children unconfirmed, so the whole signup is undone. + */ + public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Grace', + 'us_is_guardian' => '1', + 'children' => [ + ['name' => 'Ada', 'dob' => '', 'answers' => []], + ['name' => 'Alan', 'dob' => '', 'answers' => []], + ], + ]; + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); + + $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101); + $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '') + ->andReturn(new \WP_Error('link_failed', 'Nope.')); + + $deleted = []; + $this->ctx['guardians']->shouldReceive('deleteUser')->andReturnUsing( + static function (int $id) use (&$deleted): void { + $deleted[] = $id; + } + ); + + $result = $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false); + + self::assertStringContainsString('Could not create the account', $result); + self::assertSame([101, 42], $deleted); + } + + /** + * The child is who the policy binds; the guardian is who agreed. Both are + * recorded, which is what makes the acceptance legally meaningful. + */ + public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Grace', + 'us_is_guardian' => '1', + 'accept' => [3], + 'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]], + ]; + + $version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3); + $this->ctx['policies']->shouldReceive('findForScope')->andReturn([ + new Policy(title: 'Studio Terms', slug: 'terms', currentVersionId: 3, id: 1), + ]); + $this->ctx['versions']->shouldReceive('findById')->with(3)->andReturn($version); + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); + + $this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101); + + $recorded = []; + $this->ctx['acceptances']->shouldReceive('insert')->andReturnUsing( + static function (PolicyAcceptance $a) use (&$recorded): int { + $recorded[] = [$a->studentId, $a->acceptedBy]; + return 1; + } + ); + + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + Functions\expect('wp_set_current_user')->once(); + Functions\expect('wp_set_auth_cookie')->once(); + + self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false)); + + // The guardian agreed for themselves as an account holder, and for the child. + self::assertSame([[42, 42], [101, 42]], $recorded); + } + + public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void + { + $_POST = ['password' => 'password123', 'display_name' => 'Ada']; + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->justReturn(false); + + $this->ctx['guardians']->shouldNotReceive('createChild'); + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + Functions\expect('wp_set_current_user')->once(); + Functions\expect('wp_set_auth_cookie')->once(); + + self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', token: 'hash'), false)); + } } diff --git a/tests/Unit/Auth/StudentHistoryTest.php b/tests/Unit/Auth/StudentHistoryTest.php index ecc33c7..61de3e5 100644 --- a/tests/Unit/Auth/StudentHistoryTest.php +++ b/tests/Unit/Auth/StudentHistoryTest.php @@ -58,7 +58,7 @@ class StudentHistoryTest extends TestCase public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void { $this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([ - new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, null, '2026-06-02 09:00:00', 1), + new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, ipAddress: null, acceptedAt: '2026-06-02 09:00:00', id: 1), ]); $this->policyVersions->shouldReceive('findById')->with(9) ->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9)); @@ -177,9 +177,9 @@ class StudentHistoryTest extends TestCase Payment::REG_LESSON, 12, 100.00, - 'CAD', - Payment::METHOD_CARD, - Payment::STATUS_PAID, + currency: 'CAD', + method: Payment::METHOD_CARD, + status: Payment::STATUS_PAID, taxRate: 13.0, taxAmount: 13.00, receiptNumber: 'USC-7', @@ -231,7 +231,7 @@ class StudentHistoryTest extends TestCase public function testCreditsBuildDisplayRows(): void { $this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([ - new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300), + new Credit(5, 33.00, 13.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77', status: Credit::STATUS_AVAILABLE, createdAt: '2026-07-01 09:00:00', id: 300), ]); $rows = $this->history->credits(5); diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php index 266869a..9401247 100644 --- a/tests/Unit/BlockRegistrarTest.php +++ b/tests/Unit/BlockRegistrarTest.php @@ -11,6 +11,7 @@ use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\BlockRegistrar; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\FamilyPage; /** * Test double exposing editor-preview mode as a switch (the real detection @@ -41,6 +42,7 @@ class BlockRegistrarTest extends TestCase private LoginPage&Mockery\MockInterface $loginPage; private RegistrationPage&Mockery\MockInterface $registrationPage; private GroupClassPage&Mockery\MockInterface $groupClassPage; + private FamilyPage&Mockery\MockInterface $familyPage; private TestableBlockRegistrar $registrar; protected function setUp(): void @@ -51,6 +53,7 @@ class BlockRegistrarTest extends TestCase $this->loginPage = Mockery::mock(LoginPage::class); $this->registrationPage = Mockery::mock(RegistrationPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class); + $this->familyPage = Mockery::mock(FamilyPage::class); // Most requests are not a just-finished registration; the tests that // exercise that path override this. @@ -63,6 +66,7 @@ class BlockRegistrarTest extends TestCase $this->loginPage, $this->registrationPage, $this->groupClassPage, + $this->familyPage, ); } @@ -74,7 +78,7 @@ class BlockRegistrarTest extends TestCase $this->registrar->register(); } - public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void + public function testRegisterBlocksRegistersAllBlocksWithAssets(): void { Functions\expect('wp_register_script') ->once() @@ -111,6 +115,7 @@ class BlockRegistrarTest extends TestCase 'us-scheduler/student-login', 'us-scheduler/student-register', 'us-scheduler/group-classes', + 'us-scheduler/family', ], array_keys($registered) ); @@ -207,6 +212,7 @@ class BlockRegistrarTest extends TestCase $this->loginPage, $this->registrationPage, $this->groupClassPage, + $this->familyPage, ); $this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live'); diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index a551aeb..cbd324c 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -11,16 +11,19 @@ use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\Booking\Lesson; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\StudioSettings; +use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Tests\Unit\TestCase; class BookingEndpointTest extends TestCase { + private GuardianService&Mockery\MockInterface $guardians; private AvailabilityRepository $availability; private BookingRepository $bookings; private OfferingRepository $offerings; @@ -52,6 +55,14 @@ class BookingEndpointTest extends TestCase // cancellation paths simply allow the call. $this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault(); + $this->guardians = Mockery::mock(GuardianService::class); + // The default account books only for itself: no guardian link anywhere. + $this->guardians->shouldReceive('canActFor') + ->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault(); + $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); + $this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault(); + $this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault(); + $this->endpoint = new BookingEndpoint( $this->availability, $this->bookings, @@ -59,6 +70,7 @@ class BookingEndpointTest extends TestCase $this->gate, $this->payments, new CancellationPolicy($this->settings), + $this->guardians, ); } @@ -182,7 +194,7 @@ class BookingEndpointTest extends TestCase $this->gate->shouldReceive('record')->once(); $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null) + ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5) ->andReturn(new Payment( studentId: 5, instructorId: 3, @@ -260,7 +272,7 @@ class BookingEndpointTest extends TestCase $this->gate->shouldReceive('record')->once(); $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null) + ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null, null, null, 5) ->andReturn(new Payment( studentId: 5, instructorId: 3, @@ -336,8 +348,8 @@ class BookingEndpointTest extends TestCase // Three claimed occurrences at a per-lesson (one_time) price of 50 → 150. $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null) - ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + ->with(Payment::REG_LESSON, 77, 5, 3, 150.0, 'CAD', null, null, null, 5) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 150.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12)); $this->bookings->shouldNotReceive('updateStatus'); $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']); @@ -375,8 +387,8 @@ class BookingEndpointTest extends TestCase // A full_term price already covers the whole reservation. $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null) - ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + ->with(Payment::REG_LESSON, 77, 5, 3, 400.0, 'CAD', null, null, null, 5) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 400.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12)); $this->bookings->shouldNotReceive('updateStatus'); $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'recurrence' => 'weekly']); @@ -427,8 +439,8 @@ class BookingEndpointTest extends TestCase // Charged now, for a single lesson's fee, as a normal (non-scheduled) payment. $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null) - ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + ->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null, null, null, 5) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12)); $this->bookings->shouldNotReceive('updateStatus'); $result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8])); @@ -657,4 +669,131 @@ class BookingEndpointTest extends TestCase self::assertSame('Piano Lesson', $data[0]['offering_title']); self::assertSame(60, $data[0]['duration_minutes']); } + + /** + * The authorisation boundary of guardian booking: without it any signed-in + * student could book — and bill — against any user id they cared to send. + */ + public function testBookForAStudentTheCallerDoesNotGuardIsForbidden(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false); + + // Rejected before anything is looked up, claimed, or charged. + $this->availability->shouldNotReceive('findById'); + $this->availability->shouldNotReceive('claim'); + $this->bookings->shouldNotReceive('insert'); + $this->payments->shouldNotReceive('createForRegistration'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 99]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('forbidden', $result->get_error_code()); + } + + public function testGuardianBooksTheLessonInTheChildsNameAndBillsThemselves(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true); + $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5); + + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + + // The lesson belongs to the child… + $this->bookings->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 42)) + ->andReturn(77); + + // …the acceptance names the child but is attributed to the guardian… + $this->gate->shouldReceive('record') + ->once() + ->with(PolicyAcceptance::REG_LESSON, 77, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5); + + // …and the charge is raised against the child but owed by the guardian. + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 77, 42, 3, 50.0, 'CAD', null, null, null, 5) + ->andReturn(new Payment(42, 3, Payment::REG_LESSON, 77, 50.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12)); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 42]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + } + + /** + * Sending your own id explicitly is the same as sending none — no guardian + * lookup is needed to book for yourself. + */ + public function testBookForYourOwnIdNeedsNoGuardianLink(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Lesson $l): bool => $l->studentId === 5)) + ->andReturn(77); + $this->gate->shouldReceive('record')->once(); + $this->bookings->shouldReceive('updateStatus')->once()->andReturn(true); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8, 'student_id' => 5]); + + self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->book($request)); + } + + public function testGuardianMayCancelTheirChildsLesson(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true); + + $lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->bookings->shouldReceive('updateStatus')->once()->with(77, Lesson::STATUS_CANCELLED)->andReturn(true); + $this->availability->shouldReceive('release')->once()->with(10)->andReturn(true); + $this->payments->shouldReceive('voidPending')->once(); + + $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + } + + public function testMyLessonsCoversTheWholeHouseholdSortedByStart(): void + { + Functions\when('current_user_can')->justReturn(false); + + $this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]); + $this->guardians->shouldReceive('studentName')->with(5)->andReturn('Grace'); + $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada'); + + $mine = new Lesson(slotId: 11, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 78); + $childs = new Lesson(slotId: 10, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 77); + + $this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([$mine]); + $this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([$childs]); + + // Slot 10 starts first, so the child's lesson leads the merged list. + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->availability->shouldReceive('findById')->with(11)->andReturn(new AvailabilitySlot( + instructorId: 3, + startDt: '2026-07-02 10:00:00', + endDt: '2026-07-02 11:00:00', + durationMinutes: 60, + id: 11, + )); + + $data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data(); + + self::assertSame([77, 78], array_column($data, 'id')); + self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name')); + } } diff --git a/tests/Unit/Booking/BookingPageTest.php b/tests/Unit/Booking/BookingPageTest.php index 0998223..ec83b69 100644 --- a/tests/Unit/Booking/BookingPageTest.php +++ b/tests/Unit/Booking/BookingPageTest.php @@ -4,17 +4,26 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Tests\Unit\Booking; use Brain\Monkey\Functions; +use Mockery; use Unsupervised\Schedular\Booking\BookingPage; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Tests\Unit\TestCase; class BookingPageTest extends TestCase { private BookingPage $page; + private GuardianService&Mockery\MockInterface $guardians; protected function setUp(): void { parent::setUp(); - $this->page = new BookingPage(); + $this->guardians = Mockery::mock(GuardianService::class); + // Most cases are a single-student account: one bookable person, no picker. + $this->guardians->shouldReceive('bookableStudents')->andReturn( + [['id' => 3, 'name' => 'Ada', 'is_self' => true]] + )->byDefault(); + + $this->page = new BookingPage($this->guardians); } /** @@ -171,4 +180,35 @@ class BookingPageTest extends TestCase self::assertSame('https://example.com/wp-login.php', $this->page->loginUrl(5)); } + + /** + * A single-student account gets a one-entry list, which the script renders + * as no picker at all. + */ + public function testStudentListIsEmbeddedForTheScript(): void + { + $html = $this->renderForStudent([]); + + self::assertStringContainsString('data-students=', $html); + self::assertStringContainsString('"is_self":true', $html); + } + + /** + * Children lead the embedded list, so the picker's default selection is a + * child rather than the parent. + */ + public function testGuardianListLeadsWithChildren(): void + { + $this->guardians->shouldReceive('bookableStudents')->with(3)->andReturn([ + ['id' => 42, 'name' => 'Ada', 'is_self' => false], + ['id' => 3, 'name' => 'Grace', 'is_self' => true], + ]); + + $html = $this->renderForStudent([]); + + $students = json_decode(html_entity_decode((string) preg_replace('/.*data-students="([^"]*)".*/s', '$1', $html)), true); + + self::assertSame([42, 3], array_column((array) $students, 'id')); + self::assertFalse($students[0]['is_self']); + } } diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php index d9e84cc..1ef22af 100644 --- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -9,15 +9,18 @@ use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Tests\Unit\TestCase; class EnrollmentEndpointTest extends TestCase { + private GuardianService&Mockery\MockInterface $guardians; private EnrollmentRepository $enrollments; private OfferingRepository $offerings; private RegistrationGate $gate; @@ -41,12 +44,19 @@ class EnrollmentEndpointTest extends TestCase $this->payments = Mockery::mock(PaymentService::class); $this->access = Mockery::mock(GroupAccessRepository::class); + $this->guardians = Mockery::mock(GuardianService::class); + $this->guardians->shouldReceive('canActFor') + ->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault(); + $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); + $this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault(); + $this->endpoint = new EnrollmentEndpoint( $this->enrollments, $this->offerings, $this->gate, $this->payments, $this->access, + $this->guardians, ); } @@ -89,7 +99,7 @@ class EnrollmentEndpointTest extends TestCase $this->expectSuccessfulEnrollment(); $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null) + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null, null, null, 5) ->andReturn(new Payment( studentId: 5, instructorId: 3, @@ -252,4 +262,82 @@ class EnrollmentEndpointTest extends TestCase self::assertSame(200, $result->get_status()); self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']); } + + /** + * The same boundary as booking: a student id the caller may not act for is + * a 403, never a silent fallback that enrols the wrong person. + */ + public function testEnrolForAStudentTheCallerDoesNotGuardIsForbidden(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 99)->andReturn(false); + + $this->offerings->shouldNotReceive('findById'); + $this->enrollments->shouldNotReceive('insert'); + $this->payments->shouldNotReceive('createForRegistration'); + + $request = new \WP_REST_Request(['offering_id' => 8, 'student_id' => 99]); + $result = $this->endpoint->enroll($request); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('forbidden', $result->get_error_code()); + } + + public function testGuardianEnrolsTheChildAndIsBilledForIt(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true); + $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0)); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 42)->andReturn(false); + $this->enrollments->shouldReceive('countActiveForOffering')->andReturn(0); + $this->gate->shouldReceive('validate')->andReturn(null); + + $this->enrollments->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Enrollment $e): bool => $e->studentId === 42)) + ->andReturn(44); + + $this->gate->shouldReceive('record') + ->once() + ->with(PolicyAcceptance::REG_ENROLLMENT, 44, 42, 8, Mockery::any(), Mockery::any(), Mockery::any(), 5); + + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_ENROLLMENT, 44, 42, 3, 120.0, 'CAD', null, null, null, 5) + ->andReturn(new Payment(42, 3, Payment::REG_ENROLLMENT, 44, 120.0, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12)); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8, 'student_id' => 42])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + } + + public function testGuardianMayWithdrawTheirChild(): void + { + $this->guardians->shouldReceive('canActFor')->with(5, 42)->andReturn(true); + + $enrollment = new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, status: Enrollment::STATUS_ACTIVE, id: 44); + $this->enrollments->shouldReceive('findById')->with(44)->andReturn($enrollment); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0)); + $this->enrollments->shouldReceive('updateStatus')->once()->with(44, Enrollment::STATUS_CANCELLED)->andReturn(true); + $this->payments->shouldReceive('voidPending')->once(); + + self::assertInstanceOf(\WP_REST_Response::class, $this->endpoint->withdraw(new \WP_REST_Request(['id' => 44]))); + } + + public function testIndexCoversTheWholeHouseholdForAGuardian(): void + { + Functions\when('current_user_can')->justReturn(false); + + $this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5, 42]); + $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]); + $this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([ + new Enrollment(offeringId: 8, studentId: 42, instructorId: 3, id: 44), + ]); + + $data = $this->endpoint->index(new \WP_REST_Request([]))->get_data(); + + self::assertCount(1, $data); + self::assertSame(42, $data[0]['student_id']); + } } diff --git a/tests/Unit/GroupClass/GroupClassPageTest.php b/tests/Unit/GroupClass/GroupClassPageTest.php index a11ce29..a67bf78 100644 --- a/tests/Unit/GroupClass/GroupClassPageTest.php +++ b/tests/Unit/GroupClass/GroupClassPageTest.php @@ -4,20 +4,29 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Tests\Unit\GroupClass; use Brain\Monkey\Functions; +use Mockery; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Tests\Unit\TestCase; class GroupClassPageTest extends TestCase { private GroupClassPage $page; + private GuardianService&Mockery\MockInterface $guardians; protected function setUp(): void { parent::setUp(); - $this->page = new GroupClassPage(); + $this->guardians = Mockery::mock(GuardianService::class); + $this->guardians->shouldReceive('bookableStudents')->andReturn( + [['id' => 3, 'name' => 'Ada', 'is_self' => true]] + )->byDefault(); + + $this->page = new GroupClassPage($this->guardians); Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); Functions\when('current_user_can')->justReturn(true); Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_enqueue_script')->justReturn(null); diff --git a/tests/Unit/Guardian/ChildLoginGateTest.php b/tests/Unit/Guardian/ChildLoginGateTest.php new file mode 100644 index 0000000..6715a0e --- /dev/null +++ b/tests/Unit/Guardian/ChildLoginGateTest.php @@ -0,0 +1,122 @@ +gate = new ChildLoginGate(); + } + + /** + * @param array $children User IDs flagged as child accounts. + */ + private function stubChildren(array $children): void + { + Functions\when('get_user_meta')->alias( + static fn (int $userId, string $key, bool $single = false): string => in_array($userId, $children, true) ? '1' : '' + ); + } + + private function user(int $id): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->ID = $id; + + return $user; + } + + public function testRegisterHooksBothFilters(): void + { + $this->gate->register(); + + self::assertNotFalse(Filters\has('wp_authenticate_user', [$this->gate, 'blockChildLogin'])); + self::assertNotFalse(Filters\has('user_has_cap', [$this->gate, 'withholdBooking'])); + } + + public function testChildAccountCannotAuthenticate(): void + { + $this->stubChildren([42]); + + $result = $this->gate->blockChildLogin($this->user(42)); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('us_child_account', $result->get_error_code()); + } + + public function testOrdinaryStudentPassesThrough(): void + { + $this->stubChildren([42]); + + $user = $this->user(9); + + self::assertSame($user, $this->gate->blockChildLogin($user)); + } + + public function testAnEarlierAuthenticationErrorIsPassedThroughUntouched(): void + { + $this->stubChildren([42]); + + $error = new \WP_Error('bad_password', 'Nope.'); + + self::assertSame($error, $this->gate->blockChildLogin($error)); + } + + public function testBookingCapabilityIsWithheldFromAChild(): void + { + $this->stubChildren([42]); + + $caps = $this->gate->withholdBooking( + ['read' => true, RoleManager::CAP_BOOK_LESSON => true], + [], + [], + $this->user(42) + ); + + self::assertArrayNotHasKey(RoleManager::CAP_BOOK_LESSON, $caps); + self::assertTrue($caps['read']); + } + + public function testBookingCapabilityIsLeftAloneForAnOrdinaryStudent(): void + { + $this->stubChildren([42]); + + $caps = $this->gate->withholdBooking( + [RoleManager::CAP_BOOK_LESSON => true], + [], + [], + $this->user(9) + ); + + self::assertTrue($caps[RoleManager::CAP_BOOK_LESSON]); + } + + public function testNonUserSubjectIsIgnored(): void + { + $caps = [RoleManager::CAP_BOOK_LESSON => true]; + + self::assertSame($caps, $this->gate->withholdBooking($caps, [], [], null)); + } + + public function testGuardianServiceIsTheSingleSourceOfTheChildFlag(): void + { + $this->stubChildren([42]); + + self::assertTrue(GuardianService::isChild(42)); + self::assertFalse(GuardianService::isChild(9)); + } +} diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php new file mode 100644 index 0000000..4a35d66 --- /dev/null +++ b/tests/Unit/Guardian/FamilyPageTest.php @@ -0,0 +1,276 @@ +guardians = Mockery::mock(GuardianService::class); + $this->questions = Mockery::mock(QuestionRepository::class); + $this->answers = Mockery::mock(AnswerRepository::class); + + $this->page = new FamilyPage($this->guardians, $this->questions, $this->answers); + + $_POST = []; + $_GET = []; + + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(5); + Functions\when('wp_enqueue_style')->justReturn(null); + Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('get_permalink')->justReturn('https://studio.test/family/'); + Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); + Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? '')); + Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v)); + Functions\when('sanitize_textarea_field')->alias(static fn (string $v): string => trim($v)); + Functions\when('wp_unslash')->alias(static fn ($v) => $v); + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('add_query_arg')->alias( + static fn (string $key, $value, string $url): string => $url . '?' . $key . '=' . $value + ); + } + + protected function tearDown(): void + { + $_POST = []; + $_GET = []; + parent::tearDown(); + } + + /** + * A FamilyPage whose redirect is captured instead of exiting the process. + * + * @param-out string $captured + */ + private function capturingPage(?string &$captured): FamilyPage + { + $page = Mockery::mock(FamilyPage::class, [$this->guardians, $this->questions, $this->answers]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $page->shouldReceive('redirect')->andReturnUsing(static function (string $url) use (&$captured): void { + $captured = $url; + }); + + return $page; + } + + private function question(int $id, bool $required): Question + { + return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id); + } + + public function testLoggedOutVisitorIsOfferedALoginLink(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + Functions\when('wp_login_url')->justReturn('https://studio.test/wp-login.php'); + + $html = $this->page->render([]); + + self::assertStringContainsString('log in to manage your family', $html); + } + + public function testRenderListsTheGuardiansChildren(): void + { + $this->guardians->shouldReceive('children')->once()->with(5)->andReturn([ + ['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'], + ]); + $this->questions->shouldReceive('findByScope')->andReturn([]); + + $html = $this->page->render([]); + + self::assertStringContainsString('Ada', $html); + self::assertStringContainsString('2015-04-02', $html); + self::assertStringContainsString('Add a child', $html); + } + + public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void + { + $_POST = [ + 'us_family_action' => 'add', + 'child_name' => 'Ada', + 'child_dob' => '2015-04-02', + 'child_relationship' => 'Parent', + 'us_answers' => [7 => 'Piano'], + ]; + + $this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]); + $this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015-04-02', 'Parent')->andReturn(42); + + $this->answers->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Answer $a): bool => + $a->questionId === 7 + && $a->studentId === 42 + && $a->registrationId === 42 + && $a->registrationType === Answer::REG_ACCOUNT + && $a->answerValue === 'Piano')); + + $captured = null; + $this->capturingPage($captured)->maybeHandleSubmit(); + + self::assertSame('https://studio.test/family/?us_family=added', $captured); + } + + /** + * Validating before creating is what stops a missing answer from leaving a + * half-added child behind. + */ + public function testAddRefusesAMissingRequiredAnswerBeforeCreatingTheChild(): void + { + $_POST = [ + 'us_family_action' => 'add', + 'child_name' => 'Ada', + 'us_answers' => [7 => ' '], + ]; + + $this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]); + $this->guardians->shouldNotReceive('createChild'); + $this->answers->shouldNotReceive('insert'); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void + { + $_POST = ['us_family_action' => 'add', 'child_name' => '']; + + $this->questions->shouldReceive('findByScope')->once()->andReturn([]); + $this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each child a name.')); + $this->answers->shouldNotReceive('insert'); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testEditDelegatesToTheServiceAndRedirects(): void + { + $_POST = [ + 'us_family_action' => 'edit', + 'child_id' => '42', + 'child_name' => 'Ada L', + 'child_dob' => '2015-04-02', + ]; + + $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(true); + + $captured = null; + $this->capturingPage($captured)->maybeHandleSubmit(); + + self::assertSame('https://studio.test/family/?us_family=updated', $captured); + } + + public function testRemoveDelegatesToTheServiceAndRedirects(): void + { + $_POST = ['us_family_action' => 'remove', 'child_id' => '42']; + + $this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(true); + + $captured = null; + $this->capturingPage($captured)->maybeHandleSubmit(); + + self::assertSame('https://studio.test/family/?us_family=removed', $captured); + } + + public function testRemoveRefusalIsShownRatherThanRedirected(): void + { + $_POST = ['us_family_action' => 'remove', 'child_id' => '42']; + + $this->guardians->shouldReceive('removeChild')->once()->andReturn( + new \WP_Error('has_history', 'This child has lessons or enrolments on record.') + ); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testAnUnrecognisedActionDoesNothing(): void + { + $_POST = ['us_family_action' => 'destroy']; + + $this->guardians->shouldNotReceive('createChild'); + $this->guardians->shouldNotReceive('removeChild'); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testNoActionIsANoOp(): void + { + $this->guardians->shouldNotReceive('createChild'); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testLoggedOutSubmissionIsIgnored(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + $_POST = ['us_family_action' => 'remove', 'child_id' => '42']; + + $this->guardians->shouldNotReceive('removeChild'); + + $captured = null; + $page = $this->capturingPage($captured); + $page->shouldNotReceive('redirect'); + + $page->maybeHandleSubmit(); + + self::assertNull($captured); + } + + public function testCompletedActionRendersItsConfirmation(): void + { + $_GET = ['us_family' => 'added']; + + $this->guardians->shouldReceive('children')->andReturn([]); + $this->questions->shouldReceive('findByScope')->andReturn([]); + + self::assertStringContainsString('Child added.', $this->page->render([])); + } +} diff --git a/tests/Unit/Guardian/GuardianLinkTest.php b/tests/Unit/Guardian/GuardianLinkTest.php new file mode 100644 index 0000000..a1a450c --- /dev/null +++ b/tests/Unit/Guardian/GuardianLinkTest.php @@ -0,0 +1,57 @@ + '7', + 'guardian_id' => '5', + 'student_id' => '42', + 'relationship' => 'Parent', + 'created_at' => '2026-07-29 09:00:00', + ]); + + self::assertSame(7, $link->id); + self::assertSame(5, $link->guardianId); + self::assertSame(42, $link->studentId); + self::assertSame('Parent', $link->relationship); + self::assertSame('2026-07-29 09:00:00', $link->createdAt); + } + + public function testRelationshipDefaultsToEmptyWhenTheColumnIsNull(): void + { + $link = GuardianLink::fromRow((object) [ + 'id' => '7', + 'guardian_id' => '5', + 'student_id' => '42', + 'relationship' => null, + 'created_at' => null, + ]); + + self::assertSame('', $link->relationship); + self::assertNull($link->createdAt); + } + + public function testToArrayExposesEveryColumn(): void + { + $link = new GuardianLink(guardianId: 5, studentId: 42, relationship: 'Parent', createdAt: '2026-07-29 09:00:00', id: 7); + + self::assertSame( + [ + 'id' => 7, + 'guardian_id' => 5, + 'student_id' => 42, + 'relationship' => 'Parent', + 'created_at' => '2026-07-29 09:00:00', + ], + $link->toArray() + ); + } +} diff --git a/tests/Unit/Guardian/GuardianRepositoryTest.php b/tests/Unit/Guardian/GuardianRepositoryTest.php new file mode 100644 index 0000000..5f198a1 --- /dev/null +++ b/tests/Unit/Guardian/GuardianRepositoryTest.php @@ -0,0 +1,126 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new GuardianRepository($this->db); + + $this->db->shouldReceive('prepare')->andReturnUsing( + static fn (string $sql, ...$args): string => $sql . '|' . implode(',', $args) + )->byDefault(); + } + + public function testInsertStoresTheLinkAndReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-07-29 09:00:00'); + + $this->db->shouldReceive('get_row')->once()->andReturn(null); + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_guardians', + [ + 'guardian_id' => 5, + 'student_id' => 42, + 'relationship' => 'Parent', + 'created_at' => '2026-07-29 09:00:00', + ], + ['%d', '%d', '%s', '%s'] + ); + $this->db->insert_id = 7; + + self::assertSame(7, $this->repo->insert(new GuardianLink(5, 42, 'Parent'))); + } + + /** + * v1 is one guardian per child. The check lives in the repository so every + * caller — signup, the family screen, admin — gets it without repeating it. + */ + public function testInsertRefusesAChildThatAlreadyHasAGuardian(): void + { + $this->db->shouldReceive('get_row')->once()->andReturn((object) [ + 'id' => 1, + 'guardian_id' => 9, + 'student_id' => 42, + 'relationship' => '', + 'created_at' => '2026-07-01 09:00:00', + ]); + $this->db->shouldNotReceive('insert'); + + self::assertSame(0, $this->repo->insert(new GuardianLink(5, 42))); + } + + public function testFindByStudentReturnsNullWhenTheyBookForThemselves(): void + { + $this->db->shouldReceive('get_row')->once()->andReturn(null); + + self::assertNull($this->repo->findByStudent(42)); + } + + public function testFindByGuardianMapsEveryRow(): void + { + $this->db->shouldReceive('get_results')->once()->andReturn([ + (object) ['id' => 1, 'guardian_id' => 5, 'student_id' => 42, 'relationship' => '', 'created_at' => '2026-07-01 09:00:00'], + (object) ['id' => 2, 'guardian_id' => 5, 'student_id' => 43, 'relationship' => '', 'created_at' => '2026-07-02 09:00:00'], + ]); + + $links = $this->repo->findByGuardian(5); + + self::assertCount(2, $links); + self::assertSame([42, 43], array_map(static fn (GuardianLink $l): int => $l->studentId, $links)); + } + + public function testFindByGuardianReturnsAnEmptyListWhenTheQueryReturnsNull(): void + { + $this->db->shouldReceive('get_results')->once()->andReturn(null); + + self::assertSame([], $this->repo->findByGuardian(5)); + } + + public function testIsGuardianOfIsTrueOnlyForALinkedPair(): void + { + $this->db->shouldReceive('get_var')->once()->andReturn('1'); + self::assertTrue($this->repo->isGuardianOf(5, 42)); + + $this->db->shouldReceive('get_var')->once()->andReturn(null); + self::assertFalse($this->repo->isGuardianOf(5, 99)); + } + + public function testDeleteReportsWhetherARowWasRemoved(): void + { + $this->db->shouldReceive('delete') + ->once() + ->with('wp_us_guardians', ['guardian_id' => 5, 'student_id' => 42], ['%d', '%d']) + ->andReturn(1); + + self::assertTrue($this->repo->delete(5, 42)); + + $this->db->shouldReceive('delete')->once()->andReturn(0); + + self::assertFalse($this->repo->delete(5, 99)); + } + + public function testCountChildrenReturnsAnInt(): void + { + $this->db->shouldReceive('get_var')->once()->andReturn('2'); + + self::assertSame(2, $this->repo->countChildren(5)); + } +} diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php new file mode 100644 index 0000000..07b2beb --- /dev/null +++ b/tests/Unit/Guardian/GuardianServiceTest.php @@ -0,0 +1,296 @@ +> */ + private array $meta = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->guardians = Mockery::mock(GuardianRepository::class); + $this->bookings = Mockery::mock(BookingRepository::class); + $this->enrollments = Mockery::mock(EnrollmentRepository::class); + + $this->service = new GuardianService($this->guardians, $this->bookings, $this->enrollments); + + $meta = &$this->meta; + Functions\when('update_user_meta')->alias( + static function (int $userId, string $key, $value) use (&$meta): bool { + $meta[$userId][$key] = (string) $value; + return true; + } + ); + // A regular closure, not an arrow fn: arrow functions capture by value, + // so the stub would read a snapshot of the meta taken at setUp. + Functions\when('get_user_meta')->alias( + static function (int $userId, string $key, bool $single = false) use (&$meta): string { + return $meta[$userId][$key] ?? ''; + } + ); + Functions\when('delete_user_meta')->alias( + static function (int $userId, string $key) use (&$meta): bool { + unset($meta[$userId][$key]); + return true; + } + ); + Functions\when('wp_generate_password')->justReturn('abc123def456'); + Functions\when('email_exists')->justReturn(false); + Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); + } + + private function user(int $id, string $first = '', string $last = '', string $nickname = '', string $email = ''): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->ID = $id; + $user->first_name = $first; + $user->last_name = $last; + $user->nickname = $nickname; + $user->user_email = $email; + + return $user; + } + + public function testCreateChildInsertsALoginLessUserAndLinksIt(): void + { + $captured = []; + Functions\when('wp_insert_user')->alias( + static function (array $args) use (&$captured): int { + $captured = $args; + return 42; + } + ); + + $this->guardians->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent')) + ->andReturn(7); + + $result = $this->service->createChild(5, ' Ada ', '2015-04-02', 'Parent'); + + self::assertSame(42, $result); + self::assertSame('Ada', $captured['display_name']); + // The address is on the reserved .invalid TLD, so it can never receive mail. + self::assertStringEndsWith('@child.invalid', $captured['user_email']); + self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]); + self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); + } + + public function testCreateChildRejectsABlankName(): void + { + Functions\expect('wp_insert_user')->never(); + + $result = $this->service->createChild(5, ' '); + + self::assertInstanceOf(\WP_Error::class, $result); + } + + /** + * A child whose link could not be written would be an unreachable orphan + * account, so the user is removed again rather than left behind. + */ + public function testCreateChildDeletesTheUserWhenTheLinkFails(): void + { + Functions\when('wp_insert_user')->justReturn(42); + $this->guardians->shouldReceive('insert')->once()->andReturn(0); + + Functions\expect('wp_delete_user')->once()->with(42); + + self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada')); + } + + public function testCreateChildClearsAnUnparseableDateOfBirth(): void + { + Functions\when('wp_insert_user')->justReturn(42); + $this->guardians->shouldReceive('insert')->once()->andReturn(7); + + $this->service->createChild(5, 'Ada', 'not-a-date'); + + self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []); + } + + public function testCanActForSelfAndOwnChildOnly(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); + $this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false); + + self::assertTrue($this->service->canActFor(5, 5)); + self::assertTrue($this->service->canActFor(5, 42)); + self::assertFalse($this->service->canActFor(5, 99)); + } + + public function testCanActForRejectsNonPositiveIds(): void + { + self::assertFalse($this->service->canActFor(0, 42)); + self::assertFalse($this->service->canActFor(5, 0)); + } + + public function testPayerForResolvesTheGuardianAndFallsBackToTheStudent(): void + { + $this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42)); + $this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null); + + self::assertSame(5, $this->service->payerFor(42)); + self::assertSame(9, $this->service->payerFor(9)); + } + + public function testHouseholdIdsCoverTheUserAndEveryChild(): void + { + $this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([ + new GuardianLink(5, 42), + new GuardianLink(5, 43), + ]); + + self::assertSame([5, 42, 43], $this->service->householdIds(5)); + } + + /** + * The order is the feature: a guardian's default selection must be a child, + * never themselves, so a lesson meant for a kid is not booked in the + * parent's name by simply not touching the picker. + */ + public function testBookableStudentsListsChildrenBeforeTheAccountHolder(): void + { + $this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([ + new GuardianLink(5, 42), + new GuardianLink(5, 43), + ]); + + Functions\when('get_userdata')->alias(fn (int $id): \WP_User => match ($id) { + 5 => $this->user(5, 'Grace', 'Hopper'), + 42 => $this->user(42, 'Ada', 'Lovelace'), + default => $this->user(43, 'Alan', 'Turing'), + }); + + $students = $this->service->bookableStudents(5); + + self::assertSame(['Ada Lovelace', 'Alan Turing', 'Grace Hopper'], array_column($students, 'name')); + self::assertSame([42, 43, 5], array_column($students, 'id')); + self::assertSame([false, false, true], array_column($students, 'is_self')); + } + + public function testBookableStudentsIsJustTheUserWithoutChildren(): void + { + $this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]); + Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace')); + + $students = $this->service->bookableStudents(9); + + self::assertCount(1, $students); + self::assertTrue($students[0]['is_self']); + } + + public function testContactForPrefersTheGuardian(): void + { + $this->guardians->shouldReceive('findByStudent')->with(42)->andReturn(new GuardianLink(5, 42)); + Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: 'grace@example.test')); + + self::assertSame( + ['id' => 5, 'name' => 'Grace Hopper', 'email' => 'grace@example.test'], + $this->service->contactFor(42) + ); + } + + public function testContactForFallsBackToTheStudentThemselves(): void + { + $this->guardians->shouldReceive('findByStudent')->with(9)->andReturn(null); + Functions\when('get_userdata')->justReturn($this->user(9, 'Ada', 'Lovelace', email: 'ada@example.test')); + + self::assertSame( + ['id' => 9, 'name' => 'Ada Lovelace', 'email' => 'ada@example.test'], + $this->service->contactFor(9) + ); + } + + public function testUpdateChildRefusesAStudentTheCallerDoesNotGuard(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false); + Functions\expect('wp_update_user')->never(); + + self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory')); + } + + public function testUpdateChildRenamesAndStoresTheDateOfBirth(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); + Functions\expect('wp_update_user') + ->once() + ->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L']) + ->andReturn(42); + + self::assertTrue($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); + self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); + } + + public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); + $this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]); + $this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]); + $this->guardians->shouldReceive('delete')->once()->with(5, 42)->andReturn(true); + + Functions\expect('wp_delete_user')->once()->with(42); + + self::assertTrue($this->service->removeChild(5, 42)); + } + + /** + * A child's id is referenced by lessons, payments and credits, so deleting + * one with history would orphan all of it. + */ + public function testRemoveChildRefusesOnceTheyHaveLessons(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); + $this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]); + $this->guardians->shouldNotReceive('delete'); + + $result = $this->service->removeChild(5, 42); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('has_history', $result->get_error_code()); + } + + public function testRemoveChildRefusesOnceTheyHaveEnrolments(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); + $this->bookings->shouldReceive('findByStudent')->with(42)->andReturn([]); + $this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([Mockery::mock(\stdClass::class)]); + $this->guardians->shouldNotReceive('delete'); + + self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 42)); + } + + public function testRemoveChildRefusesAStudentTheCallerDoesNotGuard(): void + { + $this->guardians->shouldReceive('isGuardianOf')->with(5, 99)->andReturn(false); + $this->guardians->shouldNotReceive('delete'); + + self::assertInstanceOf(\WP_Error::class, $this->service->removeChild(5, 99)); + } + + public function testIsChildReadsTheMetaFlag(): void + { + $this->meta[42][GuardianService::META_CHILD] = '1'; + + self::assertTrue(GuardianService::isChild(42)); + self::assertFalse(GuardianService::isChild(9)); + } +} diff --git a/tests/Unit/Payment/CreditRepositoryTest.php b/tests/Unit/Payment/CreditRepositoryTest.php index 7579611..9dffc80 100644 --- a/tests/Unit/Payment/CreditRepositoryTest.php +++ b/tests/Unit/Payment/CreditRepositoryTest.php @@ -42,7 +42,7 @@ class CreditRepositoryTest extends TestCase ); $this->db->insert_id = 300; - $credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77'); + $credit = new Credit(5, 33.0, 33.0, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, reason: 'Credit for cancelled lesson #77'); self::assertSame(300, $this->repo->insert($credit)); } @@ -108,4 +108,67 @@ class CreditRepositoryTest extends TestCase $this->repo->consume(5, 0.0); } + + /** + * The balance is keyed on the payer, not the student, so a guardian's + * account carries the credits every one of their children earned. + */ + public function testAvailableBalanceQueriesThePayer(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) + ->andReturn('sql'); + $this->db->shouldReceive('get_var')->once()->with('sql')->andReturn('60.00'); + + self::assertSame(60.0, $this->repo->availableBalance(5)); + } + + public function testFindAvailableByPayerReturnsCreditsOldestFirst(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'payer_id = %d')), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE) + ->andReturn('sql'); + $this->db->shouldReceive('get_results')->once()->with('sql')->andReturn([ + (object) ['id' => '1', 'student_id' => '42', 'payer_id' => '5', 'amount' => '10.00', 'remaining' => '10.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-07-01 09:00:00', 'updated_at' => null], + ]); + + $credits = $this->repo->findAvailableByPayer(5); + + self::assertCount(1, $credits); + self::assertSame(42, $credits[0]->studentId); + self::assertSame(5, $credits[0]->payerId); + } + + /** + * Rows written before guardian accounts existed carry payer_id 0; the + * installer points them at the student who was always the payer. + */ + public function testBackfillPayerIdsPointsLegacyRowsAtTheStudent(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with('UPDATE %i SET payer_id = student_id WHERE payer_id = 0', 'wp_us_credits') + ->andReturn('sql'); + $this->db->shouldReceive('query')->once()->with('sql'); + + $this->repo->backfillPayerIds(); + } + + public function testInsertDefaultsThePayerToTheStudent(): void + { + Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_credits', + Mockery::on(static fn (array $d): bool => $d['student_id'] === 5 && $d['payer_id'] === 5), + Mockery::type('array') + ); + $this->db->insert_id = 301; + + self::assertSame(301, $this->repo->insert(new Credit(5, 10.0, 10.0))); + } } diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index 301a7cc..490fd24 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -65,7 +65,7 @@ class PaymentServiceTest extends TestCase private function payment(string $method, string $status, int $id): Payment { - return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', $method, $status, id: $id); + return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: $method, status: $status, id: $id); } public function testFreeRegistrationCreatesNoPayment(): void @@ -85,7 +85,7 @@ class PaymentServiceTest extends TestCase { // A scheduled (weekly/monthly) payment can cover several lessons and may be // collected: cancelling one lesson must never void it or trigger a rebill. - $scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60); + $scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60); $this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled); $this->payments->shouldNotReceive('updateStatus'); @@ -252,7 +252,7 @@ class PaymentServiceTest extends TestCase public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void { - $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91); + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91); $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment); $this->stripe->shouldNotReceive('createIntent'); @@ -348,7 +348,7 @@ class PaymentServiceTest extends TestCase public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void { // A paid single-lesson payment: the whole total (incl. tax) is credited. - $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12); + $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12); $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1); $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false); @@ -361,7 +361,7 @@ class PaymentServiceTest extends TestCase && $c->sourceLessonId === 77)) ->andReturn(300); $this->credits->shouldReceive('findById')->with(300)->andReturn( - new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300) + new Credit(5, 33.00, 33.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 77, id: 300) ); $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77); @@ -371,7 +371,7 @@ class PaymentServiceTest extends TestCase public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void { // A monthly scheduled charge covering 3 lessons: one cancellation credits a third. - $paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12); + $paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12); $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3); $this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false); @@ -380,7 +380,7 @@ class PaymentServiceTest extends TestCase ->once() ->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00)) ->andReturn(301); - $this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301)); + $this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 202, id: 301)); $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202); self::assertNotNull($this->service->creditForCancelledLesson($lesson)); @@ -388,7 +388,7 @@ class PaymentServiceTest extends TestCase public function testCreditForCancelledLessonSkipsUnpaidPayment(): void { - $pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12); + $pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 12); $this->payments->shouldReceive('findById')->with(12)->andReturn($pending); $this->credits->shouldNotReceive('insert'); @@ -406,7 +406,7 @@ class PaymentServiceTest extends TestCase public function testCreditForCancelledLessonSkipsAlreadyCredited(): void { - $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12); + $paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12); $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1); $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true); @@ -420,7 +420,7 @@ class PaymentServiceTest extends TestCase { // A non-anchor series lesson has no payment_id of its own; the anchor's // upfront (unscheduled) payment covers the whole 4-lesson series. - $anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12); + $anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12); $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment); $this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment); $this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4); @@ -430,7 +430,7 @@ class PaymentServiceTest extends TestCase ->once() ->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00)) ->andReturn(302); - $this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302)); + $this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, currency: 'CAD', sourcePaymentId: 12, sourceLessonId: 43, id: 302)); $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43); self::assertNotNull($this->service->creditForCancelledLesson($lesson)); @@ -487,7 +487,7 @@ class PaymentServiceTest extends TestCase private function pending(int $id, float $amount): Payment { - return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id); + return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id); } private function intentEvent(string $type, string $intentId): \Stripe\Event @@ -496,4 +496,85 @@ class PaymentServiceTest extends TestCase return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]); } + + /** + * The billing method resolves against the payer, so comping or card-billing a + * family is one setting on the guardian rather than one per child. + */ + public function testCreateForRegistrationResolvesTheBillingMethodAgainstThePayer(): void + { + $this->resolver->shouldReceive('resolve')->once()->with(5)->andReturn(Payment::METHOD_ETRANSFER); + $this->settings->shouldReceive('etransferEmail')->andReturn(''); + $this->settings->shouldReceive('hstRate')->andReturn(0.0); + + $this->payments->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Payment $p): bool => $p->studentId === 42 && $p->payerId === 5)) + ->andReturn(90); + $this->bookings->shouldReceive('setPaymentId')->once(); + $this->payments->shouldReceive('findById')->with(90)->andReturn( + new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, id: 90) + ); + + $payment = $this->service->createForRegistration(Payment::REG_LESSON, 12, 42, 3, 35.00, 'CAD', payerId: 5); + + self::assertSame(5, $payment?->payerId); + } + + /** + * A credit earned by a child lands on the guardian's balance, so one child's + * cancellation can settle a sibling's next charge. + */ + public function testCancelledChildLessonCreditsTheGuardiansBalance(): void + { + $lesson = new Lesson(slotId: 10, studentId: 42, instructorId: 3, paymentId: 12, id: 77); + $paid = new Payment(42, 3, Payment::REG_LESSON, 77, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PAID, id: 12); + + $this->payments->shouldReceive('findById')->with(12)->andReturn($paid); + $this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false); + $this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1); + + $this->credits->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 42 && $c->payerId === 5 && $c->amount === 30.0)) + ->andReturn(300); + $this->credits->shouldReceive('findById')->with(300)->andReturn( + new Credit(42, 30.00, 30.00, payerId: 5, id: 300) + ); + + self::assertNotNull($this->service->creditForCancelledLesson($lesson)); + } + + public function testApplyCreditsDrawsDownThePayersBalanceAcrossChildrensCharges(): void + { + $this->credits->shouldReceive('availableBalance')->with(5)->andReturn(60.0); + + $adasCharge = new Payment(42, 3, Payment::REG_LESSON, 12, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 91); + $alansCharge = new Payment(43, 3, Payment::REG_LESSON, 13, 30.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 92); + + $this->payments->shouldReceive('addCreditApplied')->once()->with(91, 30.0); + $this->payments->shouldReceive('addCreditApplied')->once()->with(92, 30.0); + $this->payments->shouldReceive('markPaid')->twice(); + $this->bookings->shouldReceive('findById')->andReturn(null); + $this->bookings->shouldReceive('updateStatus')->twice(); + $this->credits->shouldReceive('consume')->once()->with(5, 60.0); + + $applied = $this->service->applyCredits(5, [$adasCharge, $alansCharge]); + + self::assertSame([91 => 30.0, 92 => 30.0], $applied); + } + + /** + * A guardian paying for their child's lesson must reach the payment step; + * anyone else must not. + */ + public function testCreateIntentIsAllowedForBothTheStudentAndTheirPayer(): void + { + $payment = new Payment(42, 3, Payment::REG_LESSON, 12, 35.00, payerId: 5, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, id: 91); + $this->payments->shouldReceive('findByRegistration')->andReturn($payment); + + self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 42)); + self::assertNotNull($this->service->createIntent(Payment::REG_LESSON, 12, 5)); + self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 99)); + } } diff --git a/tests/Unit/Payment/ScheduledBillingRunnerTest.php b/tests/Unit/Payment/ScheduledBillingRunnerTest.php index b003947..f233f03 100644 --- a/tests/Unit/Payment/ScheduledBillingRunnerTest.php +++ b/tests/Unit/Payment/ScheduledBillingRunnerTest.php @@ -8,6 +8,7 @@ use Mockery; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; @@ -18,6 +19,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase; class ScheduledBillingRunnerTest extends TestCase { + private GuardianService&Mockery\MockInterface $guardians; private PaymentService $payments; private BookingRepository $bookings; private EnrollmentRepository $enrollments; @@ -49,12 +51,17 @@ class ScheduledBillingRunnerTest extends TestCase $student->user_email = 'a@b.test'; Functions\when('get_userdata')->justReturn($student); + $this->guardians = Mockery::mock(GuardianService::class); + $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); + $this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault(); + $this->runner = new ScheduledBillingRunner( $this->payments, $this->bookings, $this->enrollments, $this->offerings, - $this->mailer + $this->mailer, + $this->guardians ); } @@ -65,7 +72,7 @@ class ScheduledBillingRunnerTest extends TestCase private function pending(int $id, string $due): Payment { - return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id); + return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_ETRANSFER, status: Payment::STATUS_PENDING, dueDate: $due, id: $id); } private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object @@ -92,7 +99,7 @@ class ScheduledBillingRunnerTest extends TestCase $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', 'pay@studio.test', '2026-07-14', '2026-07-15') + ->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', 'pay@studio.test', '2026-07-14', '2026-07-15', 5) ->andReturn($this->pending(500, '2026-07-14')); $this->mailer->shouldReceive('send')->once(); @@ -124,7 +131,7 @@ class ScheduledBillingRunnerTest extends TestCase // One payment for the month: 3 x 30, due on the 1st, linked to the earliest. $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', 'pay@studio.test', '2026-07-01', '2026-07') + ->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', 'pay@studio.test', '2026-07-01', '2026-07', 5) ->andReturn($this->pending(600, '2026-07-01')); // The other two lessons are pointed at the same payment so they are not re-billed. @@ -158,11 +165,11 @@ class ScheduledBillingRunnerTest extends TestCase $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07') + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07', 5) ->andReturn($this->pending(700, '2026-07-06')); $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14') + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5) ->andReturn($this->pending(701, '2026-07-13')); $this->runner->run(); @@ -181,7 +188,7 @@ class ScheduledBillingRunnerTest extends TestCase $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14') + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14', 5) ->andReturn($this->pending(701, '2026-07-13')); $this->runner->run(); @@ -205,7 +212,7 @@ class ScheduledBillingRunnerTest extends TestCase // One payment of the monthly fee — not 4 x 20 — due on the 1st. $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07') + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5) ->andReturn($this->pending(800, '2026-07-01')); $this->runner->run(); @@ -227,7 +234,7 @@ class ScheduledBillingRunnerTest extends TestCase $this->payments->shouldReceive('createForRegistration') ->once() - ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07') + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07', 5) ->andReturn($this->pending(800, '2026-07-01')); $this->runner->run(); @@ -240,7 +247,7 @@ class ScheduledBillingRunnerTest extends TestCase ->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]); // A comp student's payment comes back paid — no due notice should be sent. - $comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_COMP, Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900); + $comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_COMP, status: Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900); $this->payments->shouldReceive('createForRegistration')->once()->andReturn($comp); $this->mailer->shouldNotReceive('send'); @@ -333,4 +340,92 @@ class ScheduledBillingRunnerTest extends TestCase id: 9, ); } + + /** + * Two children billed on the same day belong to one payer, so the guardian + * gets a single notice covering both — not one email per child — and each + * line names whose lesson it is. + */ + public function testAGuardiansChildrenShareOneNoticeWithNamedLines(): void + { + $this->now('2026-07-15 09:00:00'); + + $adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0); + $alans = $this->lessonRow(102, Offering::BILLING_WEEKLY, '2026-07-15 19:00:00', 35.0); + $adas->student_id = '42'; + $alans->student_id = '43'; + + $this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas, $alans]); + + $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5); + $this->guardians->shouldReceive('payerFor')->with(43)->andReturn(5); + $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada'); + $this->guardians->shouldReceive('studentName')->with(43)->andReturn('Alan'); + + $this->payments->shouldReceive('createForRegistration') + ->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-14')); + + // One send, two lines, each prefixed with the child it is for. + $this->mailer->shouldReceive('send') + ->once() + ->with( + Mockery::type(\WP_User::class), + Mockery::on(static function (array $items): bool { + return count($items) === 2 + && str_starts_with((string) $items[0]['label'], 'Ada: ') + && str_starts_with((string) $items[1]['label'], 'Alan: '); + }), + Mockery::type('string'), + 0.0 + ); + + $this->runner->run(); + } + + /** + * A student who pays for themselves gets the plain label — prefixing every + * line with their own name would be noise. + */ + public function testAStudentPayingForThemselvesGetsAnUnprefixedLabel(): void + { + $this->now('2026-07-15 09:00:00'); + $this->bookings->shouldReceive('findUnbilledScheduledLessons') + ->andReturn([$this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0)]); + + $this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14')); + + $this->mailer->shouldReceive('send') + ->once() + ->with( + Mockery::type(\WP_User::class), + Mockery::on(static fn (array $items): bool => str_starts_with((string) $items[0]['label'], 'Piano — ')), + Mockery::type('string'), + 0.0 + ); + + $this->runner->run(); + } + + /** + * The family balance is drawn against the payer, so a credit one child + * earned can settle a sibling's charge. + */ + public function testCreditsAreAppliedAgainstThePayerNotEachStudent(): void + { + $this->now('2026-07-15 09:00:00'); + + $adas = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0); + $adas->student_id = '42'; + + $this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$adas]); + $this->guardians->shouldReceive('payerFor')->with(42)->andReturn(5); + $this->guardians->shouldReceive('studentName')->with(42)->andReturn('Ada'); + + $this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14')); + $this->payments->shouldReceive('applyCredits')->once()->with(5, Mockery::type('array'))->andReturn([]); + + $this->mailer->shouldReceive('send')->once(); + + $this->runner->run(); + } } diff --git a/tests/Unit/Payment/StripeGatewayTest.php b/tests/Unit/Payment/StripeGatewayTest.php index d908bfa..af54010 100644 --- a/tests/Unit/Payment/StripeGatewayTest.php +++ b/tests/Unit/Payment/StripeGatewayTest.php @@ -29,7 +29,7 @@ class StripeGatewayTest extends TestCase private function payment(): Payment { - return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_CARD, Payment::STATUS_PENDING, id: 90); + return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, currency: 'CAD', method: Payment::METHOD_CARD, status: Payment::STATUS_PENDING, id: 90); } public function testCreateIntentReturnsNullWhenNotConfigured(): void diff --git a/tests/Unit/Policy/AcceptanceRepositoryTest.php b/tests/Unit/Policy/AcceptanceRepositoryTest.php index 992fe4d..c0bc48d 100644 --- a/tests/Unit/Policy/AcceptanceRepositoryTest.php +++ b/tests/Unit/Policy/AcceptanceRepositoryTest.php @@ -36,13 +36,15 @@ class AcceptanceRepositoryTest extends TestCase && $d['student_id'] === 5 && $d['registration_type'] === PolicyAcceptance::REG_LESSON && $d['registration_id'] === 12 + // No explicit acceptor: the student agreed for themselves. + && $d['accepted_by'] === 5 && $d['ip_address'] === '203.0.113.7'; }), - ['%d', '%d', '%s', '%d', '%s', '%s'] + ['%d', '%d', '%d', '%s', '%d', '%s', '%s'] ); $this->db->insert_id = 1; - $acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, '203.0.113.7'); + $acceptance = new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12, ipAddress: '203.0.113.7'); self::assertSame(1, $this->repo->insert($acceptance)); } diff --git a/tests/Unit/ShortcodeRegistrarTest.php b/tests/Unit/ShortcodeRegistrarTest.php index 1a91856..790ff17 100644 --- a/tests/Unit/ShortcodeRegistrarTest.php +++ b/tests/Unit/ShortcodeRegistrarTest.php @@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\GroupClass\GroupClassPage; +use Unsupervised\Schedular\Guardian\FamilyPage; use Unsupervised\Schedular\ShortcodeRegistrar; class ShortcodeRegistrarTest extends TestCase @@ -18,6 +19,7 @@ class ShortcodeRegistrarTest extends TestCase private LoginPage&Mockery\MockInterface $loginPage; private RegistrationPage&Mockery\MockInterface $registrationPage; private GroupClassPage&Mockery\MockInterface $groupClassPage; + private FamilyPage&Mockery\MockInterface $familyPage; private ShortcodeRegistrar $registrar; /** @var array */ @@ -34,12 +36,14 @@ class ShortcodeRegistrarTest extends TestCase $this->loginPage = Mockery::mock(LoginPage::class); $this->registrationPage = Mockery::mock(RegistrationPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class); + $this->familyPage = Mockery::mock(FamilyPage::class); $this->registrar = new ShortcodeRegistrar( $this->bookingPage, $this->loginPage, $this->registrationPage, $this->groupClassPage, + $this->familyPage, ); $shortcodes = &$this->shortcodes; @@ -50,7 +54,7 @@ class ShortcodeRegistrarTest extends TestCase ); } - public function testRegisterAddsAllFourShortcodesAndHooks(): void + public function testRegisterAddsAllShortcodesAndHooks(): void { Actions\expectAdded('template_redirect') ->once() @@ -62,7 +66,7 @@ class ShortcodeRegistrarTest extends TestCase $this->registrar->register(); self::assertSame( - ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes'], + ['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'], array_keys($this->shortcodes) ); } @@ -97,8 +101,8 @@ class ShortcodeRegistrarTest extends TestCase $scripts = $this->captureEnqueuedAssets(); self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']); - self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler']); - self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler-group']); + self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler']); + self::assertSame(['us-scheduler-pricing', 'us-scheduler-guardian'], $scripts['us-scheduler-group']); } /** diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 2424b68..8bbe7ab 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Description: Instructor/student lesson scheduling for WordPress. - * Version: 1.2.5 + * Version: 1.3.0 * Requires at least: 6.2 * Requires PHP: 8.1 * Author: Unsupervised @@ -21,7 +21,7 @@ if (! defined('ABSPATH')) { exit; } -define('USC_VERSION', '1.2.5'); +define('USC_VERSION', '1.3.0'); define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); -- 2.54.0 From 8122c158cf8ee6ca4e5639ba94a82dace5e7c0bb Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 29 Jul 2026 16:06:54 -0300 Subject: [PATCH 2/2] Keep the guardian service within the PHP the plugin supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `true` as a return type is PHP 8.2, but the plugin advertises 8.1, so the family screen's two service calls fataled on the 8.1 test job while every other job passed. They now return `?\WP_Error` — null on success — which matches RegistrationGate::validate() and works on 8.1. PHPStan was analysing against whatever PHP happened to be running (8.3 in CI, newer locally), so `composer lint` was green on syntax the plugin promises not to use. It is now pinned to the supported 8.1-8.3 range, which reproduces this failure at lint time instead of three jobs later. Co-Authored-By: Claude Opus 5 --- phpstan.neon | 7 +++++++ src/Guardian/FamilyPage.php | 8 ++++---- src/Guardian/GuardianService.php | 12 ++++++++---- tests/Unit/Guardian/FamilyPageTest.php | 4 ++-- tests/Unit/Guardian/GuardianServiceTest.php | 4 ++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 5c4121c..b0a270c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,13 @@ includes: parameters: level: 10 + # Analyse against the whole supported range, not whatever PHP happens to be + # running. Without this, syntax newer than the `Requires PHP: 8.1` header + # promises passes lint on a modern local PHP and only fails in the 8.1 test + # job — which is how a PHP 8.2 `true` return type once reached CI. + phpVersion: + min: 80100 + max: 80300 paths: - src bootstrapFiles: diff --git a/src/Guardian/FamilyPage.php b/src/Guardian/FamilyPage.php index a73468f..e93543f 100644 --- a/src/Guardian/FamilyPage.php +++ b/src/Guardian/FamilyPage.php @@ -144,18 +144,18 @@ class FamilyPage { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); - $result = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); + $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); - return $result instanceof \WP_Error ? $result : self::RESULT_UPDATED; + return $error ?? self::RESULT_UPDATED; } private function handleRemove( int $guardianId ): string|\WP_Error { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); - $result = $this->guardians->removeChild( $guardianId, $childId ); + $error = $this->guardians->removeChild( $guardianId, $childId ); - return $result instanceof \WP_Error ? $result : self::RESULT_REMOVED; + return $error ?? self::RESULT_REMOVED; } /** diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php index 52a6cb7..315d7f0 100644 --- a/src/Guardian/GuardianService.php +++ b/src/Guardian/GuardianService.php @@ -99,8 +99,10 @@ class GuardianService { * Rename a child and update their date of birth. Refuses a student the caller * is not the guardian of, so the family screen cannot be turned into an * arbitrary user editor by posting someone else's id. + * + * Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}. */ - public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): true|\WP_Error { + public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); } @@ -124,7 +126,7 @@ class GuardianService { $this->setDateOfBirth( $studentId, $dateOfBirth ); - return true; + return null; } /** @@ -132,8 +134,10 @@ class GuardianService { * lesson or enrolment history: their id is referenced by lessons, payments and * credits, and deleting the user would orphan all of it. A studio admin * handles those cases by hand. + * + * Returns null on success. */ - public function removeChild( int $guardianId, int $studentId ): true|\WP_Error { + public function removeChild( int $guardianId, int $studentId ): ?\WP_Error { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); } @@ -148,7 +152,7 @@ class GuardianService { $this->guardians->delete( $guardianId, $studentId ); $this->deleteUser( $studentId ); - return true; + return null; } /** diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php index 4a35d66..b7f183b 100644 --- a/tests/Unit/Guardian/FamilyPageTest.php +++ b/tests/Unit/Guardian/FamilyPageTest.php @@ -182,7 +182,7 @@ class FamilyPageTest extends TestCase 'child_dob' => '2015-04-02', ]; - $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(true); + $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null); $captured = null; $this->capturingPage($captured)->maybeHandleSubmit(); @@ -194,7 +194,7 @@ class FamilyPageTest extends TestCase { $_POST = ['us_family_action' => 'remove', 'child_id' => '42']; - $this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(true); + $this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(null); $captured = null; $this->capturingPage($captured)->maybeHandleSubmit(); diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php index 07b2beb..805aaae 100644 --- a/tests/Unit/Guardian/GuardianServiceTest.php +++ b/tests/Unit/Guardian/GuardianServiceTest.php @@ -236,7 +236,7 @@ class GuardianServiceTest extends TestCase ->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L']) ->andReturn(42); - self::assertTrue($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); + self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); } @@ -249,7 +249,7 @@ class GuardianServiceTest extends TestCase Functions\expect('wp_delete_user')->once()->with(42); - self::assertTrue($this->service->removeChild(5, 42)); + self::assertNull($this->service->removeChild(5, 42)); } /** -- 2.54.0