From 8c21a3fa9d9827306eb352069deeb9bdd07553b4 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 24 Aug 2026 14:06:16 -0300 Subject: [PATCH] Let the studio book lessons and record intake collected elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related gaps, closed together because the second is created by the first. A private lesson could only be booked by the student or their guardian, so a booking taken over the phone had no way in — where group classes have had "Add students directly" all along. "Book a lesson for a student" is now a panel on Scheduler and My Lessons: student, open time, lesson type, with weekly term reservations and a no-charge option for make-up lessons. The booking core is extracted to Booking\LessonBooker and shared with POST /bookings, so the two paths cannot drift on offering rules, slot claiming, or billing. That leaves a registration with no intake answers and no policy acceptances, because nobody was at a keyboard to give them — already true of every directly added group-class student. Ticking the boxes on a student's behalf would be an audit trail that says something untrue, so instead the answers are collected another way and recorded afterwards, from a lesson's or an enrolment's detail page. Every recording must say how it was collected, which is stamped on each row along with who typed it and shown in a new "How it was given" column: a policy ticked online and one transcribed from paper must never look alike. Only staff-made registrations qualify (us_lessons.booked_by, us_group_enrollments.enrolled_by) — one the student made already holds their own answers. Only what is still missing can be recorded, re-checked at write time, so a stale or double-posted form cannot duplicate or overwrite. No IP is stored for a transcription, and accepted_by stays the student while recorded_by names the staff member. Intake is now generic over Registration\IntakeSubject, which Lesson and Enrollment both implement; LessonDetail became Registration\IntakeAudit and is shared by both detail views rather than duplicated. Closes #182 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK --- CHANGELOG.md | 4 + docs/features/group-classes.md | 39 ++- docs/features/lesson-booking.md | 98 +++++- docs/features/policies.md | 12 +- docs/features/registration-questions.md | 3 + src/AdminMenu.php | 17 +- src/Booking/AdminBooking.php | 280 ++++++++++++++++ src/Booking/BookingEndpoint.php | 165 +-------- src/Booking/BookingRepository.php | 4 +- src/Booking/Lesson.php | 44 ++- src/Booking/LessonBooker.php | 234 +++++++++++++ src/Booking/LessonController.php | 162 ++++++++- src/Booking/LessonDetail.php | 87 ----- src/GroupClass/Enrollment.php | 42 ++- src/GroupClass/EnrollmentRepository.php | 3 +- src/GroupClass/GroupClassController.php | 155 ++++++++- src/Plugin.php | 9 +- src/Policy/AcceptanceRepository.php | 5 +- src/Policy/PolicyAcceptance.php | 19 ++ src/Registration/Answer.php | 15 + src/Registration/AnswerRepository.php | 5 +- src/Registration/IntakeAudit.php | 100 ++++++ src/Registration/IntakeProvenance.php | 101 ++++++ src/Registration/IntakeRecording.php | 194 +++++++++++ src/Registration/IntakeSubject.php | 49 +++ src/Registration/RegistrationGate.php | 18 +- src/RestRegistrar.php | 5 +- src/Schema.php | 8 + templates/admin/enrollment-detail.php | 190 +++++++++++ templates/admin/lesson-detail.php | 98 +++++- templates/admin/lessons.php | 91 +++++ templates/admin/my-group-class-detail.php | 6 +- tests/Unit/Auth/StudentHistoryTest.php | 10 +- tests/Unit/Booking/AdminBookingTest.php | 315 ++++++++++++++++++ tests/Unit/Booking/BookingEndpointTest.php | 5 + tests/Unit/Booking/BookingRepositoryTest.php | 6 +- tests/Unit/Booking/LessonControllerTest.php | 246 +++++++++++++- tests/Unit/Booking/LessonTest.php | 18 + .../GroupClass/EnrollmentRepositoryTest.php | 6 +- tests/Unit/GroupClass/EnrollmentTest.php | 20 +- .../GroupClass/GroupClassControllerTest.php | 111 +++++- .../Unit/Policy/AcceptanceRepositoryTest.php | 8 +- .../Registration/AnswerRepositoryTest.php | 8 +- tests/Unit/Registration/AnswerTest.php | 2 +- .../IntakeAuditTest.php} | 54 ++- .../Unit/Registration/IntakeRecordingTest.php | 255 ++++++++++++++ 46 files changed, 3020 insertions(+), 306 deletions(-) create mode 100644 src/Booking/AdminBooking.php create mode 100644 src/Booking/LessonBooker.php delete mode 100644 src/Booking/LessonDetail.php create mode 100644 src/Registration/IntakeAudit.php create mode 100644 src/Registration/IntakeProvenance.php create mode 100644 src/Registration/IntakeRecording.php create mode 100644 src/Registration/IntakeSubject.php create mode 100644 templates/admin/enrollment-detail.php create mode 100644 tests/Unit/Booking/AdminBookingTest.php rename tests/Unit/{Booking/LessonDetailTest.php => Registration/IntakeAuditTest.php} (74%) create mode 100644 tests/Unit/Registration/IntakeRecordingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 315e3cc..44bb3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ each change under the current top section as you work. ## [1.5.3] +### Added +- **Intake answers and policy agreements can now be recorded after the fact for a lesson the studio booked, or a student it added straight into a group class.** A lesson booked from wp-admin has no answers and no signed policies — nobody was at a keyboard to give them — and until now there was nowhere to put them once the studio did collect them at the first lesson or over the phone. The lesson's detail page now carries **Record intake collected elsewhere**, offering whatever is still outstanding: the unanswered questions and the policy versions with no acceptance on file. Fill in what you have, leave the rest, come back later — nothing already recorded can be overwritten, and a form posted twice cannot duplicate anything. Every recording must say **how** it was collected — a signed paper form, in person, over the phone, by email, or some other way you describe — and that answer is stamped on each entry along with your name. Both audit tables gained a **How it was given** column, so a policy accepted online and one transcribed from paper can never again look like the same thing. Only registrations the studio made have the panel: one a student made already holds their own answers, and those stay theirs alone. Group classes work the same way, reached from the new **Intake → View** link on each roster row of a class's detail page — a student added with **Add students directly** was never shown the enrolment form, and this is where what you collect instead now goes. +- **You can now book a lesson for a student yourself, from Scheduler or My Lessons.** Group classes have always had **Add students directly**, but a private lesson could only be booked by the student — or by their parent, for a child — so a booking taken over the phone, or a make-up lesson an instructor wanted to slot in, had no way in short of asking the family to go and do it themselves. **Book a lesson for a student**, a panel at the top of both lesson pages, takes the student, an open time and the lesson type and books it there and then. Tick **Reserve this time weekly** to hold the same time for the rest of the term, or **No charge** for a make-up or goodwill lesson — that one skips payment entirely and confirms the lesson immediately, where an ordinary booking raises a pending payment at the lesson type's price and confirms when it settles, exactly as a student's own booking does. The **Scheduler** reaches every instructor's open times; **My Lessons** shows an instructor only their own. Booking this way does not ask the intake questions or record the policy agreements the student would give themselves — those stay theirs to answer, so a lesson booked for someone simply shows none on its detail page. + ## [1.5.2] ### Added diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index a897bd9..575a8c8 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -15,6 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_ | `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised from the offering) | | `status` | VARCHAR(20) | `active` / `cancelled` / `completed` | | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` | +| `enrolled_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves | | `enrolled_at` | DATETIME | Insertion time | ## Class Dates, Time, and Instructor @@ -180,6 +181,8 @@ controls beneath it: settled at once by `PaymentService`). No access grant is needed — this writes straight to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and capacity, so it doubles as the **late-enrolment** path after a class has closed. + The enrolment records who added them (`enrolled_by`), which is what later allows + its intake to be recorded — see **Recording Intake Collected Elsewhere**. 2. **Make available** — the selected registered students get an `invited` grant so the class appears in their own group-class list; they then self-enrol through the normal paid flow. Each is emailed a "you've been added" notice. @@ -230,12 +233,46 @@ class becomes enrollable for them — they choose whether to enrol. instructor. The summary (`templates/admin/my-group-classes.php`) and the details page (`templates/admin/my-group-class-detail.php`) are separate templates. +## Recording Intake Collected Elsewhere +A student the studio added with **Add students directly** has no intake answers +and no policy acceptances: they were never shown the enrolment form. The answers +are collected another way — a paper form at the first class, a phone call to a +parent — and recorded afterwards from the **enrolment detail page**, reached from +the **Intake → View** link on each roster row. + +The page shows who and what the enrolment is, the audit trail of everything +answered and agreed to so far, and — for a studio-made enrolment only — a +**Record intake collected elsewhere** panel offering whatever is still missing. +Every recording must say **how** it was collected (signed paper form / in person / +over the phone / by email / some other way, the last requiring an explanation), +and that is stamped on every row along with who entered it. Both audit tables +carry a **How it was given** column, so a policy ticked online and one transcribed +from paper never look alike. + +**Only a studio-made enrolment qualifies** (`Enrollment::isStaffRegistered()`, +i.e. `enrolled_by > 0`). An enrolment the student made already holds their own +answers, and letting staff add to it would make the record editable after the +event. Nothing already recorded can be overwritten: the submission is narrowed to +what is genuinely still pending before anything is written, so a stale or +double-posted form is harmless. + +This is the same mechanism the Scheduler uses for lessons it booked, and the +reasoning behind each rule — why no IP is stored, why `accepted_by` stays the +student while `recorded_by` names the staff member — is set out once in +**Recording Intake Collected Elsewhere** in `lesson-booking.md`. An enrolment is +its own registration, so unlike a weekly lesson series there is no anchor to +follow: one enrolment, one intake record, however many sessions the term holds. + +Scoping matches the rest of the detail pages: an instructor may only open +enrolments in their own classes, a `view_all_lessons` studio admin any. + ## Implementation - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`) - Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController` -- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`) +- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`). Both also route `?enrollment_id=` to the enrolment detail view (`maybeRenderEnrollmentDetail`, template `templates/admin/enrollment-detail.php`) +- Intake audit + late recording: `Unsupervised\Schedular\Registration\IntakeAudit` and `IntakeRecording`, shared with lesson bookings. `Enrollment` implements `Registration\IntakeSubject` to take part - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown. - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 3629fe7..d7eaa21 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -17,6 +17,7 @@ Students register for a private lesson by choosing an offering, picking a time ( | `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` | | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` | | `notes` | TEXT | Optional student notes | +| `booked_by` | BIGINT UNSIGNED | Staff member who booked it for the student; 0 when the student (or their guardian) booked it themselves | | `created_at` | DATETIME | Insertion time | ## Registration Flow @@ -82,6 +83,90 @@ availability or the offering catalog, and a booking-only embed never requests `GET /bookings`. An unrecognised value renders the whole page, so a typo cannot silently hide half of it. +## Booking For A Student (Admin) +A guardian can book for their children, but nobody else can book for anyone — +which leaves the studio unable to take a booking over the phone, and an +instructor unable to slot in a make-up lesson. **Book a lesson for a student**, +a collapsed panel at the top of both **Scheduler** and **My Lessons**, is the +private-lesson counterpart to the group class's **Add students directly**. + +Pick the student, an open time, and (for a general time) the lesson type; tick +**Reserve this time weekly** for a term, **No charge** for a make-up or goodwill +lesson. The times offered are the open slots of the next eight weeks — every +instructor's on the studio **Scheduler**, only the instructor's own on **My +Lessons**, which `AdminBooking::book()` re-checks rather than trusting the +posted slot id. The result is reported as a notice above the panel saying what +was booked and what it left owing; a refusal reopens the panel with the reason. + +It is the same booking a student makes — `LessonBooker` claims the slot(s), +writes the lesson row(s), and raises the payment exactly as `POST /bookings` +does — and differs in three deliberate ways: + +1. **No intake answers or policy acceptances are recorded at booking time.** + Those are the student's to give; staff ticking the boxes for them would be an + audit trail that says something untrue. They can instead be collected some + other way and recorded afterwards — see **Recording Intake Collected + Elsewhere**. +2. **It is not bounded by what the student could book themselves**, the way a + direct group-class enrolment bypasses the enrolment deadline. +3. **It can be booked at no charge** — no payment at all, and the lesson (or + whole series) is `confirmed` at once. Without the tick a pending payment is + raised at the lesson type's price, per-occurrence for a weekly reservation, + and the lesson confirms when it settles like any other. + +A weekly reservation needs a time that actually repeats: asked for one on a +one-off slot, the form refuses (`not_weekly`) rather than quietly booking a +single lesson, since the person booking asked for a term and would otherwise +find out from the roster. + +## Recording Intake Collected Elsewhere +A lesson the studio booked has no intake answers and no policy acceptances, +because nobody was at a keyboard to give them. The studio collects them another +way — a paper form at the first lesson, a phone call — and records them +afterwards from the lesson's **detail page**: a **Record intake collected +elsewhere** panel below the two audit tables. + +**Only a staff-booked lesson has the panel** (`Lesson::isStaffRegistered()`, i.e. +`booked_by > 0`). A lesson the student booked already carries their own answers, +and letting staff add to them would make the record editable after the fact. The +same instructor/studio scoping as the rest of the detail page applies: an +instructor may only open their own lessons, the studio **Scheduler** any. + +The panel offers **only what is still missing** — questions with no answer, +current policy versions with no acceptance — and narrows the submission to that +set again before writing, so a stale or double-posted form can neither duplicate +a row nor overwrite one. Nothing is compulsory except the provenance: a studio +holding half the answers records the half it has and comes back for the rest. + +### How they were collected +Every recording must say **how** the answers reached the studio — on a signed +paper form, in person, over the phone, by email, or some other way (which must be +explained in the accompanying note). The method and note are stamped on every row +the recording writes, alongside **who typed it in**, and both audit tables carry a +**How it was given** column reading either "Given online when booking" or, say, +"On a signed paper form — Filed in the studio binder — recorded by Jane Doe". + +That column is the point of the feature. "Accepted on 24 Aug" means one thing +when a student ticked a box and quite another when a staff member transcribed it, +and an audit trail that cannot tell them apart is worse than none, because it +looks like one. + +Two details keep the record honest: + +- **No IP address is stored.** The student was never at a browser; borrowing the + staff member's would put a false location in the trail. +- **The acceptance stays in the student's name** (`accepted_by`) — they did agree, + on paper or over the phone. `recorded_by` is who entered it, which is a + different question and gets a different column. + +A weekly reservation is answered for once, so a recording made against any +occurrence lands on the series anchor (`Lesson::intakeRegistrationId()`) and +shows on every occurrence — the same rule the display side already follows. + +The whole mechanism is shared with group-class enrolments, which have the same +gap for the same reason; see **Recording Intake Collected Elsewhere** in +`group-classes.md`. + ## Cancellation Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). Cancelling marks the lesson `cancelled`, frees the availability slot for @@ -138,8 +223,8 @@ Group classes follow the same registration flow but enrol against an offering of kind `group_class`; see `group-classes.md`. ## Admin Interface -- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors -- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it. +- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors, plus the **Book a lesson for a student** panel (see below), which reaches every instructor's open times +- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor, plus the same **Book a lesson for a student** panel scoped to their own open times. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it. Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week` query params, same pattern as the availability page, bucketed via @@ -157,10 +242,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an ## Implementation - Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`) +- Booking core: `Unsupervised\Schedular\Booking\LessonBooker` — `resolveOffering()` (which offering a slot may be booked as), `reserve()` (claim the slot(s), write the lesson row(s)), `settle()` (raise the payment, or confirm when nothing is owed). Shared by `BookingEndpoint` and `AdminBooking` so the two paths cannot drift on price, payment routing, or double-booking. +- Admin booking: `Unsupervised\Schedular\Booking\AdminBooking` — `book()` (guards, then the booker) and `formData()` (the panel's student / time / lesson-type choices) +- Late intake: `Unsupervised\Schedular\Registration\IntakeRecording` — `pending()` (what is still unrecorded) and `record()` (the staff-registered guard, the dedup, then `RegistrationGate::record()` with an `IntakeProvenance`). Generic over `Registration\IntakeSubject`, which `Booking\Lesson` and `GroupClass\Enrollment` both implement +- Provenance: `Unsupervised\Schedular\Registration\IntakeProvenance` — the collection-method vocabulary, its validation, and how a stored row reads on screen. Persisted as `collected_via` / `collected_note` / `recorded_by` on both `us_question_answers` and `us_policy_acceptances`; all null/0 for anything given online. - Model: `Unsupervised\Schedular\Booking\Lesson` - Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment - Admin controller: `Unsupervised\Schedular\Booking\LessonController` -- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first. +- Admin lesson detail presenter: `Unsupervised\Schedular\Registration\IntakeAudit` (a registration's intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. Shared with the group-class enrolment detail view. A weekly series is answered for and agreed to once, against the anchor lesson, so `Lesson::intakeRegistrationId()` reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first. - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` - Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step. @@ -181,10 +270,11 @@ instructor may only open their own lessons; the studio **Scheduler** may open an > inline default. New booking-page rules should follow both conventions. ## Tests +- `tests/Unit/Booking/AdminBookingTest.php` +- `tests/Unit/Registration/IntakeRecordingTest.php`, `tests/Unit/Registration/IntakeAuditTest.php` - `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/LessonTest.php` - `tests/Unit/Booking/LessonControllerTest.php` -- `tests/Unit/Booking/LessonDetailTest.php` - `tests/Unit/Booking/BookingEndpointTest.php` ## Booking For Someone Else diff --git a/docs/features/policies.md b/docs/features/policies.md index aea0b01..8b684bd 100644 --- a/docs/features/policies.md +++ b/docs/features/policies.md @@ -36,7 +36,10 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa | `registration_type` | VARCHAR(20) | `lesson` or `enrollment` | | `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` | | `accepted_at` | DATETIME | Timestamp of acceptance | -| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail) | +| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail); NULL when not given online | +| `collected_via` | VARCHAR(20) | How the acceptance reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online | +| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` | +| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere acceptance in; 0 otherwise | ## Versioning & Acceptance Rules - Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published. @@ -91,3 +94,10 @@ cover every policy's current version or the registration is rejected. box, where that differs from `student_id` — a guardian agreeing on a child's behalf. It defaults to 0, read back as "the student agreed for themselves" (`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`. + +`recorded_by` answers a different question: who *entered* the acceptance, for one +collected on paper or over the phone and typed in afterwards. The student still +agreed, so `accepted_by` stays theirs; `collected_via` says how, and no IP is +stored because they were never at a browser. Only lessons the studio booked can +be recorded against — see **Recording Intake Collected Elsewhere** in +`lesson-booking.md`. diff --git a/docs/features/registration-questions.md b/docs/features/registration-questions.md index 9423fad..d544a41 100644 --- a/docs/features/registration-questions.md +++ b/docs/features/registration-questions.md @@ -57,6 +57,9 @@ account-holder form to differ from. | `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` | | `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` | | `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) | +| `collected_via` | VARCHAR(20) | How the answer reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online | +| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` | +| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere answer in; 0 otherwise | | `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) | | `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) | | `created_at` | DATETIME | Insertion time | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 1a6a387..395e9f1 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -17,9 +17,10 @@ use Unsupervised\Schedular\Auth\StudentActions; use Unsupervised\Schedular\Auth\StudentController; use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Auth\StudentHistory; +use Unsupervised\Schedular\Booking\AdminBooking; use Unsupervised\Schedular\Booking\BookingRepository; +use Unsupervised\Schedular\Booking\LessonBooker; use Unsupervised\Schedular\Booking\LessonController; -use Unsupervised\Schedular\Booking\LessonDetail; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassController; @@ -42,6 +43,9 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Registration\AnswerRepository; use Unsupervised\Schedular\Registration\QuestionController; use Unsupervised\Schedular\Registration\QuestionRepository; +use Unsupervised\Schedular\Registration\IntakeAudit; +use Unsupervised\Schedular\Registration\IntakeRecording; +use Unsupervised\Schedular\Registration\RegistrationGate; class AdminMenu { @@ -66,15 +70,20 @@ class AdminMenu { private PaymentController $paymentController; private PaymentReportController $paymentReportController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) { + // One audit presenter and one recorder, shared by the lesson and enrolment + // detail views: intake is the same thing whichever registration it hangs off. + $intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions ); + $intakeRecording = new IntakeRecording( $questions, $answers, $policies, $policyVersions, $acceptances, $gate ); + $this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) ); - $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) ); + $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->questionController = new QuestionController( $questions, $offerings ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->registrationController = new RegistrationController( $invites ); $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); - $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer ); + $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer, $intakeAudit, $intakeRecording ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) ); $this->instructorController = new InstructorController(); $this->settings = $settings; diff --git a/src/Booking/AdminBooking.php b/src/Booking/AdminBooking.php new file mode 100644 index 0000000..fea70e2 --- /dev/null +++ b/src/Booking/AdminBooking.php @@ -0,0 +1,280 @@ + 0 ? $this->availability->findById( $slotId ) : null; + + if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) { + return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) ); + } + + if ( $slot->isBooked ) { + return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) ); + } + + $offering = $this->booker->resolveOffering( $slot, $offeringId ); + if ( $offering instanceof \WP_Error ) { + return $offering; + } + + // A weekly reservation needs a weekly time to reserve. The student-facing + // flow quietly books a single lesson when the slot does not repeat; here the + // staff member asked for a term and must be told they are not getting one, + // rather than discovering it later on the roster. + $weekly = Lesson::RECURRENCE_WEEKLY === $recurrence; + if ( $weekly && null === $slot->recurrenceGroup ) { + return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) ); + } + + $reservation = $this->booker->reserve( + $slot, + $offering, + $studentId, + $weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, + $notes, + // Stamped on the lesson so it can be told apart later: only a lesson the + // studio booked may have its intake recorded after the fact. + get_current_user_id() + ); + + if ( $reservation instanceof \WP_Error ) { + return $reservation; + } + + $settlement = $this->booker->settle( + $reservation['ids'], + $reservation['anchor_id'], + $slot, + $offering, + $studentId, + $noCharge + ); + + return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] ); + } + + /** + * What was booked and what it left owing, so the notice answers the two things + * the person who booked it needs to know. + */ + private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string { + $who = $this->studentName( $studentId ); + + $what = $count > 1 + ? sprintf( + /* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */ + __( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ), + $who, + $offering->title, + $count, + Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ) + ) + : sprintf( + /* translators: 1: student name, 2: lesson type, 3: lesson date and time. */ + __( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ), + $who, + $offering->title, + Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) ) + ); + + $owing = Lesson::STATUS_CONFIRMED === $status + ? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' ) + : __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' ); + + return $what . ' ' . $owing; + } + + /** + * Everything the form's three selects need. `$onlyInstructorId` scopes both the + * open times and the lesson types to one instructor's, the same way `book()` + * scopes what may be booked. + * + * @return array{students: list, offerings: list, slots: list} + */ + public function formData( int $onlyInstructorId = 0 ): array { + $slots = $this->openSlots( $onlyInstructorId ); + $offerings = array_values( + array_filter( + $this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ), + static fn( Offering $o ): bool => null !== $o->id + ) + ); + + // Whose lesson type / whose time only needs saying when the page spans more + // than one instructor — on an instructor's own page it is noise. + $named = 0 === $onlyInstructorId; + + return [ + 'students' => $this->studentOptions(), + 'offerings' => array_map( + fn( Offering $o ): array => [ + 'id' => (int) $o->id, + 'label' => $this->offeringLabel( $o, $named ), + ], + $offerings + ), + 'slots' => array_map( + fn( AvailabilitySlot $s ): array => [ + 'id' => (int) $s->id, + 'label' => $this->slotLabel( $s, $named ), + 'weekly' => null !== $s->recurrenceGroup, + ], + $slots + ), + ]; + } + + /** + * Open slots inside the booking horizon, newest last. + * + * @return list + */ + private function openSlots( int $instructorId ): array { + $until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) ) + ->modify( '+' . self::HORIZON_DAYS . ' days' ) + ->format( 'Y-m-d H:i:s' ); + + return array_values( + array_filter( + $this->availability->findAvailable( $instructorId, 0, 0, '', $until ), + static fn( AvailabilitySlot $s ): bool => null !== $s->id + ) + ); + } + + /** + * A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named + * only when the list spans several. + */ + private function offeringLabel( Offering $offering, bool $withInstructor ): string { + $label = $offering->title; + + if ( null !== $offering->durationMinutes ) { + /* translators: %d: lesson length in minutes. */ + $label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')'; + } + + return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label; + } + + /** + * An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano — + * repeats weekly": when it is, how long, whose, and what it is already tied to, + * since all four decide whether a given student can be booked into it. + */ + private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string { + /* translators: %d: lesson length in minutes. */ + $label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')'; + + if ( $withInstructor ) { + $label .= ' — ' . $this->instructorName( $slot->instructorId ); + } + + $tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null; + if ( null !== $tied ) { + $label .= ' — ' . $tied->title; + } + + if ( null !== $slot->recurrenceGroup ) { + $label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' ); + } + + return $label; + } + + private function instructorName( int $instructorId ): string { + return $this->studentName( $instructorId ); + } + + /** A person's display name, however little the account has on file. */ + private function studentName( int $userId ): string { + $user = get_userdata( $userId ); + + return UserName::format( $user instanceof \WP_User ? $user : null, $userId ); + } + + /** + * Everyone who can be booked for, by name — students and the children a + * guardian books for alike, since both hold `book_lesson`. + * + * @return list + */ + private function studentOptions(): array { + $users = array_filter( + get_users( + [ + 'role' => RoleManager::STUDENT, + 'orderby' => 'display_name', + 'order' => 'ASC', + ] + ), + static fn( mixed $u ): bool => $u instanceof \WP_User + ); + + return array_values( + array_map( + static fn( \WP_User $u ): array => [ + 'id' => (int) $u->ID, + 'name' => UserName::format( $u, (int) $u->ID ), + ], + $users + ) + ); + } +} diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 1f13980..c6f19f3 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -7,9 +7,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\GroupClass\SessionSchedule; 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; @@ -17,18 +15,13 @@ use Unsupervised\Schedular\Val; class BookingEndpoint { - /** - * The most occurrences a single weekly booking may reserve at once, so one - * student cannot lock up an instructor's entire recurring schedule. - */ - private const MAX_WEEKLY_OCCURRENCES = 12; - public function __construct( private AvailabilityRepository $availability, private BookingRepository $bookings, private OfferingRepository $offerings, private RegistrationGate $gate, private PaymentService $payments, + private LessonBooker $booker, private CancellationPolicy $cancellationPolicy, private GuardianService $guardians, private SessionSchedule $sessions, @@ -227,52 +220,12 @@ class BookingEndpoint { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } - // Resolve the offering for this booking. A client-supplied offering must - // never override the slot's price or payment routing: when the slot is tied - // to a specific offering that offering is authoritative, and any offering - // used must belong to the slot's instructor. This prevents substituting a - // cheaper/free offering to dodge payment, or another instructor's offering - // to misroute it. - $requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); - $slotOfferingId = (int) ( $slot->offeringId ?? 0 ); - - if ( $slotOfferingId > 0 ) { - if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) { - return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - $offeringId = $slotOfferingId; - } else { - $offeringId = $requestedOfferingId; + $offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) ); + if ( $offering instanceof \WP_Error ) { + return $offering; } - // Every lesson books against an offering: it carries the price, intake - // questions, and payment routing. Without one the booking would silently - // be free and unquestioned, so generic slots require the student's choice. - if ( $offeringId <= 0 ) { - return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - - $offering = $this->offerings->findById( $offeringId ); - if ( null === $offering ) { - return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - - if ( $offering->instructorId !== $slot->instructorId ) { - return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - - // A slot-tied offering was the instructor's explicit choice and is honoured - // as-is; a student-chosen one must be something the catalog actually offers - // for this slot: an active private-lesson type whose length fits the slot. - if ( 0 === $slotOfferingId ) { - if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) { - return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - - if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) { - return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); - } - } + $offeringId = (int) $offering->id; $answers = $this->answers( $request ); $acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) ); @@ -282,93 +235,28 @@ class BookingEndpoint { return $gateError; } - $notes = Val::string( $request->get_param( 'notes' ) ); - $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) - ? Lesson::RECURRENCE_WEEKLY - : Lesson::RECURRENCE_SINGLE; + $notes = Val::string( $request->get_param( 'notes' ) ); - $template = new Lesson( - slotId: $slotId, - studentId: $studentId, - instructorId: $slot->instructorId, - offeringId: $offeringId, - recurrence: $recurrence, - notes: '' !== $notes ? $notes : null, + $reservation = $this->booker->reserve( + $slot, + $offering, + $studentId, + Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, + $notes ); - // Weekly reservation across the slot's recurring group; otherwise a single lesson. - if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) { - // Claim each occurrence atomically (capped so one booking cannot lock an - // instructor's entire schedule), then create a lesson only for the slots - // this request actually won — never for one already taken by someone else. - $candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); - $candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES ); - - $claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) ); - if ( [] === $claimed ) { - return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); - } - - $ids = $this->bookings->insertSeries( $template, $claimed ); - $anchorId = $ids[0] ?? 0; - } else { - // Claim before inserting: if another request already took the slot, the - // guarded update reports no rows and we reject rather than double-book. - if ( ! $this->availability->claim( $slotId ) ) { - return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); - } - $anchorId = $this->bookings->insert( $template ); - $ids = [ $anchorId ]; + if ( $reservation instanceof \WP_Error ) { + return $reservation; } + $ids = $reservation['ids']; + $anchorId = $reservation['anchor_id']; + // 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; - - // Scheduled billing (weekly / monthly) normally defers payment to the daily - // scan, but a single lesson booked once its scheduled due date has already - // passed — e.g. an extra lesson added to a month that was already billed — is - // charged at booking instead, so it is never missed or billed late. - $chargeAtBooking = $offering->price > 0.0 && ( - ! $offering->isScheduledBilling() - || ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) ) - ); - - if ( $chargeAtBooking ) { - // A full-term price already covers the whole reservation; a per-lesson - // (one_time) price is owed once per occurrence actually claimed, so a - // weekly reservation cannot hold a term while paying for one week. - $amount = Offering::BILLING_FULL_TERM === $offering->billingMode - ? $offering->price - : $offering->price * count( $ids ); - - $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; - } - } else { - // Either a free offering, or scheduled billing (weekly / monthly) whose - // payment is deferred to the daily billing scan. Either way there is no - // payment step now to confirm the lessons, so the reserved slots are - // confirmed at booking time; the billing scan bills them when they come due. - foreach ( $ids as $lessonId ) { - $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED ); - } - $status = Lesson::STATUS_CONFIRMED; - } + [ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId ); // `payment: null` tells the front end to skip the payment step entirely. return new \WP_REST_Response( @@ -424,23 +312,6 @@ class BookingEndpoint { return $out; } - /** - * Whether a scheduled-billing offering's due date for a given session has - * already passed at booking time. Weekly bills 24 hours before the lesson; - * monthly bills on the 1st, so its due moment has passed once "now" is in the - * lesson's month or later. Only meaningful for weekly / monthly offerings. - */ - private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool { - $now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ); - $start = new \DateTimeImmutable( $slotStart ); - - if ( Offering::BILLING_MONTHLY === $offering->billingMode ) { - return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' ); - } - - return $now >= $start->modify( '-1 day' ); - } - private function clientIp(): ?string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index a3adfe8..b867c37 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -24,9 +24,10 @@ class BookingRepository { 'status' => $lesson->status, 'payment_id' => $lesson->paymentId, 'notes' => $lesson->notes, + 'booked_by' => $lesson->bookedBy, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ] + [ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s' ] ); return $this->db->insert_id; @@ -54,6 +55,7 @@ class BookingRepository { seriesId: $seriesId > 0 ? $seriesId : null, status: $template->status, notes: $template->notes, + bookedBy: $template->bookedBy, ) ); diff --git a/src/Booking/Lesson.php b/src/Booking/Lesson.php index 874efa3..a40f39f 100644 --- a/src/Booking/Lesson.php +++ b/src/Booking/Lesson.php @@ -3,9 +3,11 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Booking; +use Unsupervised\Schedular\Registration\Answer; +use Unsupervised\Schedular\Registration\IntakeSubject; use Unsupervised\Schedular\Val; -class Lesson { +class Lesson implements IntakeSubject { public const STATUS_PENDING = 'pending'; public const STATUS_CONFIRMED = 'confirmed'; @@ -38,9 +40,47 @@ class Lesson { public readonly string $status = self::STATUS_PENDING, public readonly ?int $paymentId = null, public readonly ?string $notes = null, + /** + * The staff member who booked this lesson on the student's behalf, from + * wp-admin; 0 when it was booked through the student-facing flow, by the + * student or their guardian. It is what marks a lesson whose intake answers + * and policy acceptances may be recorded after the fact — nobody was at a + * keyboard to give them at booking time. + */ + public readonly int $bookedBy = 0, public readonly ?int $id = null, ) {} + public function intakeRegistrationType(): string { + return Answer::REG_LESSON; + } + + /** + * The lesson id this booking's intake answers and policy acceptances hang + * off: the series anchor for a weekly reservation, the lesson itself + * otherwise. A series is answered for and agreed to once, so every occurrence + * reads and writes the same registration. + */ + public function intakeRegistrationId(): int { + return $this->seriesId ?? (int) $this->id; + } + + public function intakeOfferingId(): int { + return (int) $this->offeringId; + } + + public function intakeStudentId(): int { + return $this->studentId; + } + + /** + * Whether the studio booked this lesson on the student's behalf, rather than + * the student (or their guardian) booking it themselves. + */ + public function isStaffRegistered(): bool { + return $this->bookedBy > 0; + } + public static function fromRow( \stdClass $row ): self { return new self( slotId: Val::int( $row->slot_id ), @@ -52,6 +92,7 @@ class Lesson { status: Val::string( $row->status ), paymentId: Val::intOrNull( $row->payment_id ), notes: Val::stringOrNull( $row->notes ), + bookedBy: Val::int( $row->booked_by ?? 0 ), id: Val::int( $row->id ), ); } @@ -73,6 +114,7 @@ class Lesson { 'status' => $this->status, 'payment_id' => $this->paymentId, 'notes' => $this->notes, + 'booked_by' => $this->bookedBy, ]; } } diff --git a/src/Booking/LessonBooker.php b/src/Booking/LessonBooker.php new file mode 100644 index 0000000..515ab93 --- /dev/null +++ b/src/Booking/LessonBooker.php @@ -0,0 +1,234 @@ +offeringId ?? 0 ); + + if ( $slotOfferingId > 0 ) { + if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) { + return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + $offeringId = $slotOfferingId; + } else { + $offeringId = $requestedOfferingId; + } + + // Every lesson books against an offering: it carries the price, intake + // questions, and payment routing. Without one the booking would silently + // be free and unquestioned, so generic slots require an explicit choice. + if ( $offeringId <= 0 ) { + return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + + $offering = $this->offerings->findById( $offeringId ); + if ( null === $offering ) { + return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + + if ( $offering->instructorId !== $slot->instructorId ) { + return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + + // A slot-tied offering was the instructor's explicit choice and is honoured + // as-is; a chosen one must be something the catalog actually offers for this + // slot: an active private-lesson type whose length fits the slot. + if ( 0 === $slotOfferingId ) { + if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) { + return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + + if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) { + return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + } + + return $offering; + } + + /** + * Claim the slot(s) and write the lesson row(s) — a single lesson, or one per + * remaining occurrence of the slot's weekly group. The rows are created + * `pending`; `settle()` decides what confirms them. + * + * `$bookedBy` is the staff member booking on the student's behalf, and 0 for a + * booking made through the student-facing flow. It is recorded on every lesson + * of a series, since a series is booked once. + * + * @return array{ids: list, anchor_id: int}|\WP_Error + */ + public function reserve( AvailabilitySlot $slot, Offering $offering, int $studentId, string $recurrence, ?string $notes = null, int $bookedBy = 0 ): array|\WP_Error { + $slotId = (int) $slot->id; + $recurrence = Lesson::RECURRENCE_WEEKLY === $recurrence ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE; + + $template = new Lesson( + slotId: $slotId, + studentId: $studentId, + instructorId: $slot->instructorId, + offeringId: (int) $offering->id, + recurrence: $recurrence, + notes: null !== $notes && '' !== $notes ? $notes : null, + bookedBy: $bookedBy, + ); + + // Weekly reservation across the slot's recurring group; otherwise a single lesson. + if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) { + // Claim each occurrence atomically (capped so one booking cannot lock an + // instructor's entire schedule), then create a lesson only for the slots + // this request actually won — never for one already taken by someone else. + $candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); + $candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES ); + + $claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) ); + if ( [] === $claimed ) { + return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); + } + + $ids = $this->bookings->insertSeries( $template, $claimed ); + + return [ + 'ids' => $ids, + 'anchor_id' => $ids[0] ?? 0, + ]; + } + + // Claim before inserting: if another request already took the slot, the + // guarded update reports no rows and we reject rather than double-book. + if ( ! $this->availability->claim( $slotId ) ) { + return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); + } + + $anchorId = $this->bookings->insert( $template ); + + return [ + 'ids' => [ $anchorId ], + 'anchor_id' => $anchorId, + ]; + } + + /** + * Raise the payment for a reservation and report the status its lessons end up + * in. A priced booking stays `pending` until its payment settles; anything with + * nothing to charge now — a free offering, scheduled billing, or a booking the + * caller marked `$noCharge` — is confirmed here and then. + * + * @param list $ids + * + * @return array{status: string, payment: ?Payment} + */ + public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array { + // Scheduled billing (weekly / monthly) normally defers payment to the daily + // scan, but a single lesson booked once its scheduled due date has already + // passed — e.g. an extra lesson added to a month that was already billed — is + // charged at booking instead, so it is never missed or billed late. + $chargeAtBooking = ! $noCharge && $offering->price > 0.0 && ( + ! $offering->isScheduledBilling() + || ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) ) + ); + + if ( $chargeAtBooking ) { + // A full-term price already covers the whole reservation; a per-lesson + // (one_time) price is owed once per occurrence actually claimed, so a + // weekly reservation cannot hold a term while paying for one week. + $amount = Offering::BILLING_FULL_TERM === $offering->billingMode + ? $offering->price + : $offering->price * count( $ids ); + + $payment = $this->payments->createForRegistration( + Payment::REG_LESSON, + $anchorId, + $studentId, + $slot->instructorId, + $amount, + $offering->currency, + $offering->etransferEmail, + payerId: $this->guardians->payerFor( $studentId ) + ); + + return [ + 'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING, + 'payment' => $payment, + ]; + } + + // Either nothing is owed — a free offering, or a booking the studio comped — + // or scheduled billing (weekly / monthly) whose payment is deferred to the + // daily billing scan. Either way there is no payment step now to confirm the + // lessons, so the reserved slots are confirmed at booking time; the billing + // scan bills the scheduled ones when they come due. + foreach ( $ids as $lessonId ) { + $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED ); + } + + return [ + 'status' => Lesson::STATUS_CONFIRMED, + 'payment' => null, + ]; + } + + /** + * Whether a scheduled-billing offering's due date for a lesson has already + * gone by — monthly bills on the first of the lesson's month, weekly the day + * before the lesson. + */ + private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool { + $now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ); + $start = new \DateTimeImmutable( $slotStart ); + + if ( Offering::BILLING_MONTHLY === $offering->billingMode ) { + return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' ); + } + + return $now >= $start->modify( '-1 day' ); + } +} diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index 5c8bba2..2cf40f9 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -10,6 +10,9 @@ use Unsupervised\Schedular\Availability\WeekCalendar; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Registration\IntakeAudit; +use Unsupervised\Schedular\Registration\IntakeProvenance; +use Unsupervised\Schedular\Registration\IntakeRecording; use Unsupervised\Schedular\Val; class LessonController { @@ -19,7 +22,9 @@ class LessonController { private PaymentRepository $payments, private AvailabilityRepository $availability, private OfferingRepository $offerings, - private LessonDetail $detail, + private IntakeAudit $detail, + private AdminBooking $adminBooking, + private IntakeRecording $intake, ) {} public function renderAdminDashboard(): void { @@ -31,11 +36,11 @@ class LessonController { return; } - $this->handleEtransferUpdate( false ); + [ $notice, $error ] = $this->handleFormAction( false, 0 ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); - $this->renderLessonsPage( $rows, 'us-scheduler' ); + $this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error ); } public function renderInstructorLessons(): void { @@ -47,11 +52,13 @@ class LessonController { return; } - $this->handleEtransferUpdate( true ); + $instructorId = get_current_user_id(); - $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); + [ $notice, $error ] = $this->handleFormAction( true, $instructorId ); - $this->renderLessonsPage( $rows, 'us-my-lessons' ); + $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) ); + + $this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error ); } /** @@ -68,15 +75,23 @@ class LessonController { $lesson = $this->repository->findById( $lessonId ); $backUrl = admin_url( 'admin.php?page=' . $pageSlug ); + $notice = ''; + $error = ''; if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) { $row = null; $answers = []; $accepts = []; + $intake = $this->emptyIntake(); } else { + // Recorded before the tables are read, so what was just entered appears + // on the page that reports it. + [ $notice, $error ] = $this->recordIntake( $lesson ); + $row = $this->row( $lesson ); $answers = $this->detail->answers( $lesson ); $accepts = $this->detail->acceptances( $lesson ); + $intake = $this->intakeForm( $lesson ); } include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php'; @@ -84,13 +99,86 @@ class LessonController { return true; } + /** + * Handle a submitted "record intake collected elsewhere" form. + * + * @return array{string, string} Success notice and error message. + */ + private function recordIntake( Lesson $lesson ): array { + if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { + return [ '', '' ]; + } + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. + if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) { + return [ '', '' ]; + } + + $answers = []; + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below. + foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) { + $answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion. + $rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] ); + $versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) ); + + $result = $this->intake->record( + $lesson, + $answers, + $versionIds, + sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ), + sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ), + get_current_user_id() + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + return $result instanceof \WP_Error + ? [ '', $result->get_error_message() ] + : [ $result, '' ]; + } + + /** + * What the detail template needs to offer the recording form: whether this + * lesson qualifies at all, what is still missing, and the collection methods + * to choose between. + * + * @return array{recordable: bool, questions: list, policies: list, methods: array} + */ + private function intakeForm( Lesson $lesson ): array { + if ( ! $lesson->isStaffRegistered() ) { + return $this->emptyIntake(); + } + + return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ]; + } + + /** + * The form data for a lesson that cannot be recorded against — one the student + * booked, or one that could not be opened at all. + * + * @return array{recordable: bool, questions: list, policies: list, methods: array} + */ + private function emptyIntake(): array { + return [ + 'recordable' => false, + 'questions' => [], + 'policies' => [], + 'methods' => [], + ]; + } + /** * Render the lessons template with its calendar view state: week (default) - * or list, plus which week the week view shows. + * or list, plus which week the week view shows, and the choices the + * book-for-a-student form offers — scoped to one instructor's own schedule on + * **My Lessons**, studio-wide (0) on the **Scheduler**. * * @param list> $rows */ - private function renderLessonsPage( array $rows, string $pageSlug ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template. + private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void { // View-state query params only (which view, which week) — nothing is // mutated from them, so no nonce applies. // phpcs:disable WordPress.Security.NonceVerification.Recommended @@ -103,21 +191,67 @@ class LessonController { $prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' ); $nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' ); $baseUrl = admin_url( 'admin.php?page=' . $pageSlug ); + $bookForm = $this->adminBooking->formData( $onlyInstructorId ); include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; } /** - * Handle a per-lesson payment override (e-transfer email or HST rate). When - * $onlyOwn, the payment must belong to the current instructor. + * Run the submitted action and report what happened: a per-lesson payment + * override (e-transfer email or HST rate), or a lesson booked for a student. + * When $onlyOwn, the payment or slot must belong to the current instructor. + * + * @return array{string, string} Success notice and error message; each is + * empty when it does not apply. */ - private function handleEtransferUpdate( bool $onlyOwn ): void { + private function handleFormAction( bool $onlyOwn, int $instructorId ): array { if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) { - return; + return [ '', '' ]; } - // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. - $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ); + + if ( 'book_for_student' === $action ) { + return $this->bookForStudent( $onlyOwn ? $instructorId : 0 ); + } + + $this->updatePayment( $action, $onlyOwn ); + + return [ '', '' ]; + } + + /** + * Book a lesson on a student's behalf from the submitted form. The slot is + * scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId + * non-zero) and studio-wide on the **Scheduler**. + * + * @return array{string, string} + */ + private function bookForStudent( int $onlyInstructorId ): array { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. + $result = $this->adminBooking->book( + absint( Val::int( $_POST['student_id'] ?? 0 ) ), + absint( Val::int( $_POST['slot_id'] ?? 0 ) ), + absint( Val::int( $_POST['offering_id'] ?? 0 ) ), + isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, + isset( $_POST['no_charge'] ), + sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ), + $onlyInstructorId + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + return $result instanceof \WP_Error + ? [ '', $result->get_error_message() ] + : [ $result, '' ]; + } + + /** + * Apply a per-lesson payment override. When $onlyOwn, the payment must belong + * to the current instructor. + */ + private function updatePayment( string $action, bool $onlyOwn ): void { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); $email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. diff --git a/src/Booking/LessonDetail.php b/src/Booking/LessonDetail.php deleted file mode 100644 index 596124a..0000000 --- a/src/Booking/LessonDetail.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ - public function answers( Lesson $lesson ): array { - return array_map( - function ( Answer $answer ): array { - $question = $this->questions->findById( $answer->questionId ); - $value = $answer->answerValue ?? ''; - - return [ - 'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ), - 'answer' => '' === $value ? '—' : $value, - ]; - }, - $this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) ) - ); - } - - /** - * The policy versions the student accepted when booking this lesson, with the - * captured acceptance time and IP for the audit trail. - * - * @return list - */ - public function acceptances( Lesson $lesson ): array { - return array_map( - function ( PolicyAcceptance $acceptance ): array { - $version = $this->versions->findById( $acceptance->policyVersionId ); - $policy = $version ? $this->policies->findById( $version->policyId ) : null; - - return [ - 'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ), - 'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—', - 'accepted_at' => $acceptance->acceptedAt ?? '', - 'ip' => $acceptance->ipAddress ?? '', - ]; - }, - $this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) ) - ); - } - - /** - * The lesson id the booking's answers and acceptances were recorded against: - * the series anchor for a weekly reservation, the lesson itself otherwise. - */ - private function registrationId( Lesson $lesson ): int { - return $lesson->seriesId ?? (int) $lesson->id; - } -} diff --git a/src/GroupClass/Enrollment.php b/src/GroupClass/Enrollment.php index 5b75802..68d4402 100644 --- a/src/GroupClass/Enrollment.php +++ b/src/GroupClass/Enrollment.php @@ -3,9 +3,11 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; +use Unsupervised\Schedular\Registration\Answer; +use Unsupervised\Schedular\Registration\IntakeSubject; use Unsupervised\Schedular\Val; -class Enrollment { +class Enrollment implements IntakeSubject { public const STATUS_ACTIVE = 'active'; public const STATUS_CANCELLED = 'cancelled'; @@ -24,9 +26,45 @@ class Enrollment { public readonly int $instructorId, public readonly string $status = self::STATUS_ACTIVE, public readonly ?int $paymentId = null, + /** + * The staff member who enrolled this student from wp-admin — the class + * detail page's **Add students directly**; 0 when the student or their + * guardian enrolled themselves. It is what marks an enrolment whose intake + * answers and policy acceptances may be recorded after the fact, nobody + * having been at a keyboard to give them at the time. + */ + public readonly int $enrolledBy = 0, public readonly ?int $id = null, ) {} + public function intakeRegistrationType(): string { + return Answer::REG_ENROLLMENT; + } + + /** + * An enrolment is registered once and is its own registration — there is no + * series anchor to follow, as a term of classes is one enrolment. + */ + public function intakeRegistrationId(): int { + return (int) $this->id; + } + + public function intakeOfferingId(): int { + return $this->offeringId; + } + + public function intakeStudentId(): int { + return $this->studentId; + } + + /** + * Whether the studio enrolled this student, rather than the student (or their + * guardian) enrolling themselves. + */ + public function isStaffRegistered(): bool { + return $this->enrolledBy > 0; + } + public static function fromRow( \stdClass $row ): self { return new self( offeringId: Val::int( $row->offering_id ), @@ -34,6 +72,7 @@ class Enrollment { instructorId: Val::int( $row->instructor_id ), status: Val::string( $row->status ), paymentId: Val::intOrNull( $row->payment_id ), + enrolledBy: Val::int( $row->enrolled_by ?? 0 ), id: Val::int( $row->id ), ); } @@ -51,6 +90,7 @@ class Enrollment { 'instructor_id' => $this->instructorId, 'status' => $this->status, 'payment_id' => $this->paymentId, + 'enrolled_by' => $this->enrolledBy, ]; } } diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php index ed978da..b6a1001 100644 --- a/src/GroupClass/EnrollmentRepository.php +++ b/src/GroupClass/EnrollmentRepository.php @@ -20,9 +20,10 @@ class EnrollmentRepository { 'instructor_id' => $enrollment->instructorId, 'status' => $enrollment->status, 'payment_id' => $enrollment->paymentId, + 'enrolled_by' => $enrollment->enrolledBy, 'enrolled_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%d', '%s', '%d', '%s' ] + [ '%d', '%d', '%d', '%s', '%d', '%d', '%s' ] ); return $this->db->insert_id; diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 451ed22..f69ed1f 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -14,6 +14,9 @@ use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Registration\IntakeAudit; +use Unsupervised\Schedular\Registration\IntakeProvenance; +use Unsupervised\Schedular\Registration\IntakeRecording; use Unsupervised\Schedular\Val; class GroupClassController { @@ -26,6 +29,8 @@ class GroupClassController { private PaymentService $paymentService, private InviteRepository $invites, private RegistrationMailer $mailer, + private IntakeAudit $audit, + private IntakeRecording $intake, ) {} /** @@ -41,13 +46,18 @@ class GroupClassController { wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) ); } + $baseUrl = admin_url( 'admin.php?page=us-group-classes' ); + + if ( $this->maybeRenderEnrollmentDetail( $baseUrl, 0 ) ) { + return; + } + $notice = ''; if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) { $notice = $this->handleFormAction( get_current_user_id() ); } $offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS ); - $baseUrl = admin_url( 'admin.php?page=us-group-classes' ); // View-state query param only (which class to drill into) — nothing is // mutated from it, so no nonce applies. @@ -104,6 +114,10 @@ class GroupClassController { $instructorId = get_current_user_id(); + if ( $this->maybeRenderEnrollmentDetail( admin_url( 'admin.php?page=us-my-group-classes' ), $instructorId ) ) { + return; + } + $notice = ''; if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) { $notice = $this->handleFormAction( $instructorId ); @@ -144,6 +158,138 @@ class GroupClassController { include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php'; } + /** + * When the request targets a single enrolment (`?enrollment_id=`), render its + * detail view — the audit trail of what the student answered and agreed to, + * and, for an enrolment the studio made, the form to record intake collected + * elsewhere. Reports whether the page has been handled. + * + * `$onlyInstructorId` scopes it the way the pages themselves are scoped: an + * instructor may only open enrolments in their own classes, while the studio + * **Group Classes** page passes 0 and may open any. + */ + private function maybeRenderEnrollmentDetail( string $baseUrl, int $onlyInstructorId ): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only enrolment selector. + $enrollmentId = absint( Val::int( $_GET['enrollment_id'] ?? 0 ) ); + if ( $enrollmentId <= 0 ) { + return false; + } + + $enrollment = $this->enrollments->findById( $enrollmentId ); + $notice = ''; + $error = ''; + + if ( null === $enrollment || ( $onlyInstructorId > 0 && $enrollment->instructorId !== $onlyInstructorId ) ) { + $row = null; + $answers = []; + $accepts = []; + $intake = $this->emptyIntake(); + } else { + // Recorded before the tables are read, so what was just entered appears + // on the page that reports it. + [ $notice, $error ] = $this->recordIntake( $enrollment ); + + $row = $this->enrollmentRow( $enrollment ); + $answers = $this->audit->answers( $enrollment ); + $accepts = $this->audit->acceptances( $enrollment ); + $intake = $this->intakeForm( $enrollment ); + } + + include USC_PLUGIN_DIR . 'templates/admin/enrollment-detail.php'; + + return true; + } + + /** + * Who and what one enrolment is, for the head of its detail view. + * + * @return array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string} + */ + private function enrollmentRow( Enrollment $enrollment ): array { + $student = get_userdata( $enrollment->studentId ); + $offering = $this->offerings->findById( $enrollment->offeringId ); + $payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null; + + return [ + 'enrollment_id' => (int) $enrollment->id, + 'student' => UserName::format( $student instanceof \WP_User ? $student : null, $enrollment->studentId ), + 'class' => null !== $offering ? $offering->title : '—', + 'instructor' => null !== $offering ? $this->instructorName( $offering ) : '—', + 'status' => $enrollment->status, + 'payment' => null !== $payment ? $payment->status : '—', + ]; + } + + /** + * Handle a submitted "record intake collected elsewhere" form. + * + * @return array{string, string} Success notice and error message. + */ + private function recordIntake( Enrollment $enrollment ): array { + if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_group_action' ) ) { + return [ '', '' ]; + } + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. + if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) { + return [ '', '' ]; + } + + $answers = []; + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below. + foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) { + $answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion. + $rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] ); + $versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) ); + + $result = $this->intake->record( + $enrollment, + $answers, + $versionIds, + sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ), + sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ), + get_current_user_id() + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + return $result instanceof \WP_Error + ? [ '', $result->get_error_message() ] + : [ $result, '' ]; + } + + /** + * What the detail template needs to offer the recording form: whether this + * enrolment qualifies at all, what is still missing, and the collection + * methods to choose between. + * + * @return array{recordable: bool, questions: list, policies: list, methods: array} + */ + private function intakeForm( Enrollment $enrollment ): array { + if ( ! $enrollment->isStaffRegistered() ) { + return $this->emptyIntake(); + } + + return [ 'recordable' => true ] + $this->intake->pending( $enrollment ) + [ 'methods' => IntakeProvenance::choices() ]; + } + + /** + * The form data for an enrolment that cannot be recorded against — one the + * student made, or one that could not be opened at all. + * + * @return array{recordable: bool, questions: list, policies: list, methods: array} + */ + private function emptyIntake(): array { + return [ + 'recordable' => false, + 'questions' => [], + 'policies' => [], + 'methods' => [], + ]; + } + /** * Summary row for one class in the instructor overview: its identity, when it * meets, and how many active enrolments it holds against capacity. @@ -176,7 +322,7 @@ class GroupClassController { * invite-only classes — the list of people invited but not yet enrolled. * * @param list $enrollments - * @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} + * @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} */ private function classDetail( Offering $offering, array $enrollments ): array { $roster = []; @@ -189,6 +335,7 @@ class GroupClassController { $payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null; $roster[] = [ + 'id' => (int) $enrollment->id, 'student' => $student ? $student->display_name : (string) $enrollment->studentId, 'status' => $enrollment->status, 'payment' => $payment?->status, @@ -334,6 +481,10 @@ class GroupClassController { offeringId: (int) $offering->id, studentId: $studentId, instructorId: $offering->instructorId, + // Stamped so this enrolment can be told apart later: only one the + // studio made may have its intake recorded after the fact, the + // student never having been asked the questions. + enrolledBy: get_current_user_id(), ) ); diff --git a/src/Plugin.php b/src/Plugin.php index 5cd5e8f..1028cde 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -16,6 +16,7 @@ use Unsupervised\Schedular\Auth\StudentAdminGuard; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; +use Unsupervised\Schedular\Booking\LessonBooker; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassPage; @@ -101,6 +102,10 @@ class Plugin { $stripe = new StripeGateway( $settings ); $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe, $creditRepo ); + // The booking core is shared by the REST endpoint students book through and + // the admin form staff book on their behalf with. + $lessonBooker = new LessonBooker( $availability, $bookings, $offerings, $paymentService, $guardians ); + // The shortcode and block wrappers share the same page objects so // front-end output is identical whichever way a page embeds them. $registrationMailer = new RegistrationMailer(); @@ -121,8 +126,8 @@ class Plugin { ( new StudentAdminGuard() )->register(); ( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->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, $guardians ) )->register(); - ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); } diff --git a/src/Policy/AcceptanceRepository.php b/src/Policy/AcceptanceRepository.php index f5c1d4f..69d51b7 100644 --- a/src/Policy/AcceptanceRepository.php +++ b/src/Policy/AcceptanceRepository.php @@ -21,9 +21,12 @@ class AcceptanceRepository { 'registration_type' => $acceptance->registrationType, 'registration_id' => $acceptance->registrationId, 'ip_address' => $acceptance->ipAddress, + 'collected_via' => $acceptance->collectedVia, + 'collected_note' => $acceptance->collectedNote, + 'recorded_by' => $acceptance->recordedBy, 'accepted_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ] + [ '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ] ); return $this->db->insert_id; diff --git a/src/Policy/PolicyAcceptance.php b/src/Policy/PolicyAcceptance.php index f364ff8..4bffae1 100644 --- a/src/Policy/PolicyAcceptance.php +++ b/src/Policy/PolicyAcceptance.php @@ -32,6 +32,19 @@ class PolicyAcceptance { */ public readonly int $acceptedBy = 0, public readonly ?string $ipAddress = null, + /** + * How this acceptance reached the studio when it was not given online — see + * {@see \Unsupervised\Schedular\Registration\IntakeProvenance}. Null is the + * ordinary case: the student ticked the box themselves. + */ + public readonly ?string $collectedVia = null, + public readonly ?string $collectedNote = null, + /** + * The staff member who typed it in, when somebody did. Distinct from + * `acceptedBy`: the student still agreed, on paper or over the phone — this + * is only who entered the record of it. + */ + public readonly int $recordedBy = 0, public readonly ?string $acceptedAt = null, public readonly ?int $id = null, ) {} @@ -44,6 +57,9 @@ class PolicyAcceptance { registrationId: Val::int( $row->registration_id ), acceptedBy: Val::int( $row->accepted_by ?? 0 ), ipAddress: Val::stringOrNull( $row->ip_address ), + collectedVia: Val::stringOrNull( $row->collected_via ?? null ), + collectedNote: Val::stringOrNull( $row->collected_note ?? null ), + recordedBy: Val::int( $row->recorded_by ?? 0 ), acceptedAt: Val::stringOrNull( $row->accepted_at ), id: Val::int( $row->id ), ); @@ -81,6 +97,9 @@ class PolicyAcceptance { 'registration_type' => $this->registrationType, 'registration_id' => $this->registrationId, 'ip_address' => $this->ipAddress, + 'collected_via' => $this->collectedVia, + 'collected_note' => $this->collectedNote, + 'recorded_by' => $this->recordedBy, 'accepted_at' => $this->acceptedAt, ]; } diff --git a/src/Registration/Answer.php b/src/Registration/Answer.php index bd9b3d5..769c41c 100644 --- a/src/Registration/Answer.php +++ b/src/Registration/Answer.php @@ -25,6 +25,15 @@ class Answer { public readonly int $registrationId, public readonly int $studentId, public readonly ?string $answerValue = null, + /** + * How this answer reached the studio when it did not come from the booking + * form — see {@see IntakeProvenance}. Null is the ordinary case: the student + * typed it in themselves. + */ + public readonly ?string $collectedVia = null, + public readonly ?string $collectedNote = null, + /** The staff member who typed it in, when somebody did. */ + public readonly int $recordedBy = 0, public readonly ?int $id = null, ) {} @@ -35,6 +44,9 @@ class Answer { registrationId: Val::int( $row->registration_id ), studentId: Val::int( $row->student_id ), answerValue: Val::stringOrNull( $row->answer_value ), + collectedVia: Val::stringOrNull( $row->collected_via ?? null ), + collectedNote: Val::stringOrNull( $row->collected_note ?? null ), + recordedBy: Val::int( $row->recorded_by ?? 0 ), id: Val::int( $row->id ), ); } @@ -52,6 +64,9 @@ class Answer { 'registration_id' => $this->registrationId, 'student_id' => $this->studentId, 'answer_value' => $this->answerValue, + 'collected_via' => $this->collectedVia, + 'collected_note' => $this->collectedNote, + 'recorded_by' => $this->recordedBy, ]; } } diff --git a/src/Registration/AnswerRepository.php b/src/Registration/AnswerRepository.php index a16b090..833b5be 100644 --- a/src/Registration/AnswerRepository.php +++ b/src/Registration/AnswerRepository.php @@ -20,9 +20,12 @@ class AnswerRepository { 'registration_id' => $answer->registrationId, 'student_id' => $answer->studentId, 'answer_value' => $answer->answerValue, + 'collected_via' => $answer->collectedVia, + 'collected_note' => $answer->collectedNote, + 'recorded_by' => $answer->recordedBy, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%s', '%d', '%d', '%s', '%s' ] + [ '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ] ); return $this->db->insert_id; diff --git a/src/Registration/IntakeAudit.php b/src/Registration/IntakeAudit.php new file mode 100644 index 0000000..b711db1 --- /dev/null +++ b/src/Registration/IntakeAudit.php @@ -0,0 +1,100 @@ + + */ + public function answers( IntakeSubject $subject ): array { + return array_map( + function ( Answer $answer ): array { + $question = $this->questions->findById( $answer->questionId ); + $value = $answer->answerValue ?? ''; + + return [ + 'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ), + 'answer' => '' === $value ? '—' : $value, + 'source' => $this->source( $answer->collectedVia, $answer->collectedNote, $answer->recordedBy ), + ]; + }, + $this->answers->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() ) + ); + } + + /** + * Where a recorded row came from: given online by the student, or collected + * some other way and typed in — in which case who typed it is named, since an + * unattributed transcription is worth much less than an attributed one. + */ + private function source( ?string $collectedVia, ?string $collectedNote, int $recordedBy ): string { + $described = IntakeProvenance::describe( $collectedVia, $collectedNote ); + + if ( null === $collectedVia || '' === $collectedVia || $recordedBy <= 0 ) { + return $described; + } + + $user = get_userdata( $recordedBy ); + + return sprintf( + /* translators: 1: how the answer was collected, 2: name of the staff member who recorded it. */ + __( '%1$s — recorded by %2$s', 'unsupervised-schedular' ), + $described, + UserName::format( $user instanceof \WP_User ? $user : null, $recordedBy ) + ); + } + + /** + * The policy versions the student accepted for this registration, with the + * captured acceptance time and IP for the audit trail. + * + * @return list + */ + public function acceptances( IntakeSubject $subject ): array { + return array_map( + function ( PolicyAcceptance $acceptance ): array { + $version = $this->versions->findById( $acceptance->policyVersionId ); + $policy = $version ? $this->policies->findById( $version->policyId ) : null; + + return [ + 'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ), + 'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—', + 'accepted_at' => $acceptance->acceptedAt ?? '', + 'ip' => $acceptance->ipAddress ?? '', + 'source' => $this->source( $acceptance->collectedVia, $acceptance->collectedNote, $acceptance->recordedBy ), + ]; + }, + $this->acceptances->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() ) + ); + } +} diff --git a/src/Registration/IntakeProvenance.php b/src/Registration/IntakeProvenance.php new file mode 100644 index 0000000..2af4bee --- /dev/null +++ b/src/Registration/IntakeProvenance.php @@ -0,0 +1,101 @@ + + */ + public const VALID_METHODS = [ self::VIA_PAPER, self::VIA_IN_PERSON, self::VIA_PHONE, self::VIA_EMAIL, self::VIA_OTHER ]; + + /** Longest note the `collected_note` VARCHAR(191) column holds. */ + public const MAX_NOTE_LENGTH = 191; + + public function __construct( + public readonly string $collectedVia, + public readonly ?string $collectedNote = null, + public readonly int $recordedBy = 0, + ) {} + + /** + * Build from submitted values, or explain what is wrong with them. A method + * outside the vocabulary is rejected rather than stored: a column that can say + * anything says nothing. `other` requires the note, since "other" on its own + * answers the question with the question. + */ + public static function fromInput( string $collectedVia, string $collectedNote, int $recordedBy ): self|\WP_Error { + if ( ! in_array( $collectedVia, self::VALID_METHODS, true ) ) { + return new \WP_Error( 'invalid_collection_method', __( 'Choose how these were collected.', 'unsupervised-schedular' ) ); + } + + $note = trim( $collectedNote ); + + if ( self::VIA_OTHER === $collectedVia && '' === $note ) { + return new \WP_Error( 'collection_note_required', __( 'Say how these were collected.', 'unsupervised-schedular' ) ); + } + + return new self( + collectedVia: $collectedVia, + collectedNote: '' !== $note ? mb_substr( $note, 0, self::MAX_NOTE_LENGTH ) : null, + recordedBy: $recordedBy, + ); + } + + /** + * The methods as `value => label`, for the form's picker and for reading a + * stored value back on screen. + * + * @return array + */ + public static function choices(): array { + return [ + self::VIA_PAPER => __( 'On a signed paper form', 'unsupervised-schedular' ), + self::VIA_IN_PERSON => __( 'In person', 'unsupervised-schedular' ), + self::VIA_PHONE => __( 'Over the phone', 'unsupervised-schedular' ), + self::VIA_EMAIL => __( 'By email', 'unsupervised-schedular' ), + self::VIA_OTHER => __( 'Some other way', 'unsupervised-schedular' ), + ]; + } + + /** + * How a stored row reads on screen: the method's label, plus its note. An + * empty method is the ordinary case — the student answered online — and says + * so rather than showing a blank cell. + */ + public static function describe( ?string $collectedVia, ?string $collectedNote = null ): string { + if ( null === $collectedVia || '' === $collectedVia ) { + return __( 'Given online when booking', 'unsupervised-schedular' ); + } + + $label = self::choices()[ $collectedVia ] ?? $collectedVia; + $note = null !== $collectedNote ? trim( $collectedNote ) : ''; + + return '' !== $note ? $label . ' — ' . $note : $label; + } +} diff --git a/src/Registration/IntakeRecording.php b/src/Registration/IntakeRecording.php new file mode 100644 index 0000000..45cea3a --- /dev/null +++ b/src/Registration/IntakeRecording.php @@ -0,0 +1,194 @@ +, policies: list} + */ + public function pending( IntakeSubject $subject ): array { + $type = $subject->intakeRegistrationType(); + $registrationId = $subject->intakeRegistrationId(); + + $answered = array_map( + static fn( Answer $a ): int => $a->questionId, + $this->answers->findByRegistration( $type, $registrationId ) + ); + + $accepted = array_map( + static fn( PolicyAcceptance $a ): int => $a->policyVersionId, + $this->acceptances->findByRegistration( $type, $registrationId ) + ); + + $questions = []; + foreach ( $this->questions->findByOffering( $subject->intakeOfferingId(), true ) as $question ) { + if ( in_array( (int) $question->id, $answered, true ) ) { + continue; + } + + $questions[] = [ + 'id' => (int) $question->id, + 'label' => $question->label, + 'required' => $this->isRequiredOf( $question ), + ]; + } + + $policies = []; + foreach ( $this->gate->requiredPolicyVersionIds() as $versionId ) { + if ( in_array( $versionId, $accepted, true ) ) { + continue; + } + + $version = $this->versions->findById( $versionId ); + $policy = null !== $version ? $this->policies->findById( $version->policyId ) : null; + + $policies[] = [ + 'version_id' => $versionId, + 'policy' => null !== $policy ? $policy->title : sprintf( '#%d', $versionId ), + 'version' => null !== $version ? sprintf( 'v%d', $version->versionNumber ) : '—', + ]; + } + + return [ + 'questions' => $questions, + 'policies' => $policies, + ]; + } + + /** + * Record what the studio collected elsewhere, returning the notice to show. + * + * Submitted answers and acceptances are narrowed to what is actually still + * pending before anything is written, so a stale form — reloaded, or posted + * twice — can neither duplicate a row nor overwrite one. + * + * @param array $answers question_id => answer value + * @param list $versionIds Policy version ids being accepted + * + * @return string|\WP_Error + */ + public function record( IntakeSubject $subject, array $answers, array $versionIds, string $collectedVia, string $collectedNote, int $recordedBy ): string|\WP_Error { + if ( ! $subject->isStaffRegistered() ) { + return new \WP_Error( + 'not_recordable', + __( 'Intake can only be recorded for a registration the studio made on the student\'s behalf.', 'unsupervised-schedular' ) + ); + } + + $provenance = IntakeProvenance::fromInput( $collectedVia, $collectedNote, $recordedBy ); + if ( $provenance instanceof \WP_Error ) { + return $provenance; + } + + $pending = $this->pending( $subject ); + + $pendingQuestionIds = array_map( static fn( array $q ): int => $q['id'], $pending['questions'] ); + $pendingVersionIds = array_map( static fn( array $p ): int => $p['version_id'], $pending['policies'] ); + + $newAnswers = []; + foreach ( $answers as $questionId => $value ) { + $value = trim( $value ); + if ( '' !== $value && in_array( (int) $questionId, $pendingQuestionIds, true ) ) { + $newAnswers[ (int) $questionId ] = $value; + } + } + + $newVersionIds = array_values( array_intersect( $versionIds, $pendingVersionIds ) ); + + if ( [] === $newAnswers && [] === $newVersionIds ) { + return new \WP_Error( 'nothing_to_record', __( 'Nothing was filled in to record.', 'unsupervised-schedular' ) ); + } + + // No IP address is passed: the student was not at a browser, and borrowing + // the staff member's would put a false location in the audit trail. Nor is + // an acceptor — the student agreed, on paper or over the phone; who typed it + // in is `recorded_by`, which the provenance carries. + $this->gate->record( + $subject->intakeRegistrationType(), + $subject->intakeRegistrationId(), + $subject->intakeStudentId(), + $subject->intakeOfferingId(), + $newAnswers, + $newVersionIds, + null, + 0, + $provenance + ); + + return $this->notice( count( $newAnswers ), count( $newVersionIds ), $provenance ); + } + + /** What was written, and how it was said to have been collected. */ + private function notice( int $answers, int $acceptances, IntakeProvenance $provenance ): string { + $parts = []; + + if ( $answers > 0 ) { + /* translators: %d: number of intake answers recorded. */ + $parts[] = sprintf( _n( '%d answer', '%d answers', $answers, 'unsupervised-schedular' ), $answers ); + } + + if ( $acceptances > 0 ) { + /* translators: %d: number of policy acceptances recorded. */ + $parts[] = sprintf( _n( '%d policy acceptance', '%d policy acceptances', $acceptances, 'unsupervised-schedular' ), $acceptances ); + } + + return sprintf( + /* translators: 1: what was recorded, e.g. "2 answers and 1 policy acceptance", 2: how they were collected. */ + __( 'Recorded %1$s, collected: %2$s', 'unsupervised-schedular' ), + implode( __( ' and ', 'unsupervised-schedular' ), $parts ), + IntakeProvenance::describe( $provenance->collectedVia, $provenance->collectedNote ) + ); + } + + /** + * Whether the question is one the booking form would have insisted on, of + * either audience. It is shown as a hint only — a studio that has half the + * answers should be able to record the half it has, rather than being made to + * invent the rest to get the form to submit. + */ + private function isRequiredOf( Question $question ): bool { + return $question->isRequired || $question->isRequiredChild; + } +} diff --git a/src/Registration/IntakeSubject.php b/src/Registration/IntakeSubject.php new file mode 100644 index 0000000..5a543da --- /dev/null +++ b/src/Registration/IntakeSubject.php @@ -0,0 +1,49 @@ + $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, int $acceptedBy = 0 ): void { + public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0, ?IntakeProvenance $provenance = null ): void { foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) { $value = (string) ( $answers[ (int) $question->id ] ?? '' ); if ( '' === $value ) { @@ -79,6 +83,9 @@ class RegistrationGate { registrationId: $registrationId, studentId: $studentId, answerValue: $value, + collectedVia: $provenance?->collectedVia, + collectedNote: $provenance?->collectedNote, + recordedBy: null !== $provenance ? $provenance->recordedBy : 0, ) ); } @@ -96,17 +103,22 @@ class RegistrationGate { registrationId: $registrationId, acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId, ipAddress: $ipAddress, + collectedVia: $provenance?->collectedVia, + collectedNote: $provenance?->collectedNote, + recordedBy: null !== $provenance ? $provenance->recordedBy : 0, ) ); } } /** - * Current published version IDs of every booking-scoped policy. + * Current published version IDs of every booking-scoped policy — what a + * booking must accept, and so also what a late, collected-elsewhere recording + * has to offer. * * @return list */ - private function requiredPolicyVersionIds(): array { + public function requiredPolicyVersionIds(): array { $ids = []; foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) { diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 1bdddab..e581d7e 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -9,6 +9,7 @@ use Unsupervised\Schedular\Availability\WindowValidator; use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\CancellationPolicy; +use Unsupervised\Schedular\Booking\LessonBooker; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; @@ -39,9 +40,9 @@ 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, GuardianService $guardians ) { + 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, LessonBooker $booker ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) ); - $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) ); + $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); diff --git a/src/Schema.php b/src/Schema.php index c6f7a2c..4abdd76 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -40,6 +40,7 @@ class Schema { status VARCHAR(20) NOT NULL DEFAULT 'pending', payment_id BIGINT UNSIGNED DEFAULT NULL, notes TEXT, + booked_by BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY slot_id (slot_id), @@ -104,6 +105,9 @@ class Schema { registration_id BIGINT UNSIGNED NOT NULL, student_id BIGINT UNSIGNED NOT NULL, answer_value TEXT, + collected_via VARCHAR(20) DEFAULT NULL, + collected_note VARCHAR(191) DEFAULT NULL, + recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY question_id (question_id), @@ -145,6 +149,9 @@ class Schema { registration_id BIGINT UNSIGNED NOT NULL, accepted_at DATETIME NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, + collected_via VARCHAR(20) DEFAULT NULL, + collected_note VARCHAR(191) DEFAULT NULL, + recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY policy_version_id (policy_version_id), KEY student_id (student_id), @@ -210,6 +217,7 @@ class Schema { instructor_id BIGINT UNSIGNED NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', payment_id BIGINT UNSIGNED DEFAULT NULL, + enrolled_by BIGINT UNSIGNED NOT NULL DEFAULT 0, enrolled_at DATETIME NOT NULL, PRIMARY KEY (id), KEY offering_id (offering_id), diff --git a/templates/admin/enrollment-detail.php b/templates/admin/enrollment-detail.php new file mode 100644 index 0000000..270050c --- /dev/null +++ b/templates/admin/enrollment-detail.php @@ -0,0 +1,190 @@ + $answers + * @var list $accepts + * @var string $baseUrl + * @var string $notice + * @var string $error + * @var array{recordable: bool, questions: list, policies: list, methods: array} $intake + */ +?> +
+

+ +

«

+ + +

+ + + +

+ + + +

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

+ +

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

+ +

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

+

+ +

+ + +

+ +
+ + + + +

+ +

+ +

+ +

+ + + +

+ + + + + + + + +

+ + +

+ + + + + + + + + + +

+ +

+
+ + + +
diff --git a/templates/admin/lesson-detail.php b/templates/admin/lesson-detail.php index e382a6d..498064d 100644 --- a/templates/admin/lesson-detail.php +++ b/templates/admin/lesson-detail.php @@ -7,9 +7,12 @@ if (! defined('ABSPATH')) { /** * @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row - * @var list $answers - * @var list $accepts + * @var list $answers + * @var list $accepts * @var string $backUrl + * @var string $notice + * @var string $error + * @var array{recordable: bool, questions: list, policies: list, methods: array} $intake */ ?>
@@ -17,6 +20,14 @@ if (! defined('ABSPATH')) {

«

+ +

+ + + +

+ +

@@ -81,6 +92,7 @@ if (! defined('ABSPATH')) { + @@ -90,6 +102,7 @@ if (! defined('ABSPATH')) { + @@ -105,6 +118,7 @@ if (! defined('ABSPATH')) { + @@ -112,10 +126,90 @@ if (! defined('ABSPATH')) { + + + +

+

+ +

+ + +

+ +
+ + + + +

+ +

+ +

+ +

+ + + +

+ + + + + + + + +

+ + +

+ + + + + + + + + + +

+ +

+
+ +
diff --git a/templates/admin/lessons.php b/templates/admin/lessons.php index d2b13c9..3dad459 100644 --- a/templates/admin/lessons.php +++ b/templates/admin/lessons.php @@ -13,11 +13,102 @@ if (! defined('ABSPATH')) { * @var string $prevWeek * @var string $nextWeek * @var string $baseUrl + * @var string $notice + * @var string $error + * @var array{students: list, offerings: list, slots: list} $bookForm */ ?>

+ +

+ + + +

+ + +
> + + + +

+ +

+ +
+ + +

+ +

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

+ +

+
+ +
+
  • > | diff --git a/templates/admin/my-group-class-detail.php b/templates/admin/my-group-class-detail.php index 0ebdee7..03bc939 100644 --- a/templates/admin/my-group-class-detail.php +++ b/templates/admin/my-group-class-detail.php @@ -6,7 +6,7 @@ if (! defined('ABSPATH')) { } /** - * @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} $class + * @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} $class * @var list $students * @var string $notice * @var string $baseUrl @@ -122,6 +122,7 @@ if (! defined('ABSPATH')) { + @@ -130,6 +131,9 @@ if (! defined('ABSPATH')) { + + + diff --git a/tests/Unit/Auth/StudentHistoryTest.php b/tests/Unit/Auth/StudentHistoryTest.php index 61de3e5..bb8da39 100644 --- a/tests/Unit/Auth/StudentHistoryTest.php +++ b/tests/Unit/Auth/StudentHistoryTest.php @@ -98,7 +98,7 @@ class StudentHistoryTest extends TestCase public function testIntakeAnswersResolveQuestionLabels(): void { $this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([ - new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', 1), + new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', id: 1), ]); $this->questions->shouldReceive('findById')->with(4) ->andReturn(new Question(1, 'Experience level', id: 4)); @@ -120,7 +120,7 @@ class StudentHistoryTest extends TestCase public function testIntakeAnswersFallBackWhenQuestionIsGone(): void { $this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([ - new Answer(4, Answer::REG_LESSON, 12, 5, null, 1), + new Answer(4, Answer::REG_LESSON, 12, 5, null, id: 1), ]); $this->questions->shouldReceive('findById')->with(4)->andReturn(null); @@ -133,8 +133,8 @@ class StudentHistoryTest extends TestCase public function testIntakeAnswersExcludeAccountScopeAnswers(): void { $this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([ - new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2), - new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1), + new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', id: 2), + new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', id: 1), ]); // Only the booking-scoped answer is resolved; the account answer is dropped. $this->questions->shouldReceive('findById')->with(4) @@ -150,7 +150,7 @@ class StudentHistoryTest extends TestCase public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void { $this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([ - new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1), + new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', id: 1), ]); $this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([ new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4), diff --git a/tests/Unit/Booking/AdminBookingTest.php b/tests/Unit/Booking/AdminBookingTest.php new file mode 100644 index 0000000..a20be7a --- /dev/null +++ b/tests/Unit/Booking/AdminBookingTest.php @@ -0,0 +1,315 @@ +alias(static fn ($v): int => abs((int) $v)); + Functions\when('current_time')->justReturn('2026-06-01 10:00:00'); + Functions\when('mysql2date')->alias( + static fn (string $format, string $date): string => date($format, (int) strtotime($date)) + ); + Functions\when('get_userdata')->justReturn(false); + Functions\when('get_users')->justReturn([]); + // Everyone offered in the picker can book; the guard is exercised on its own. + Functions\when('user_can')->justReturn(true); + // The staff member doing the booking; stamped on the lesson as booked_by. + Functions\when('get_current_user_id')->justReturn(3); + + $this->availability = Mockery::mock(AvailabilityRepository::class); + $this->bookings = Mockery::mock(BookingRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->payments = Mockery::mock(PaymentService::class); + $this->guardians = Mockery::mock(GuardianService::class); + $this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault(); + + // The real booker over mocked repositories: an admin booking must go + // through exactly the machinery a student's own booking does. + $this->admin = new AdminBooking( + $this->availability, + $this->offerings, + new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians) + ); + } + + public function testBooksASingleLessonAndRaisesAPendingPayment(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); + + $this->bookings->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Lesson $l): bool => 7 === $l->slotId + && 42 === $l->studentId + && 9 === $l->instructorId + && 3 === $l->offeringId + && Lesson::RECURRENCE_SINGLE === $l->recurrence + && 'Booked by phone' === $l->notes + // Stamped with who booked it, which is what later lets the studio + // record the intake it never had a chance to collect. + && 3 === $l->bookedBy + ))->andReturn(100); + + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42) + ->andReturn($this->pendingPayment()); + + $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone'); + + self::assertIsString($notice); + self::assertStringContainsString('30 min piano', $notice); + self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice); + self::assertStringContainsString('pending payment', $notice); + } + + public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(100); + + // The whole point of the no-charge tick: a priced offering raises nothing. + $this->payments->shouldReceive('createForRegistration')->never(); + $this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true); + + $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, ''); + + self::assertIsString($notice); + self::assertStringContainsString('Nothing is owed', $notice); + } + + public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void + { + $slot = $this->slot(recurrenceGroup: 55); + + $this->availability->shouldReceive('findById')->with(7)->andReturn($slot); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + $this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([ + $slot, + $this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55), + $this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55), + ]); + $this->availability->shouldReceive('claim')->times(3)->andReturn(true); + $this->bookings->shouldReceive('insertSeries')->once() + ->with(Mockery::type(Lesson::class), [7, 8, 9]) + ->andReturn([100, 101, 102]); + + // A per-lesson (one_time) price is owed once per occurrence claimed. + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42) + ->andReturn($this->pendingPayment()); + + $notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, ''); + + self::assertIsString($notice); + self::assertStringContainsString('3 weekly lessons', $notice); + } + + public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + + // Nothing is claimed or written: the staff member asked for a term and is + // told they cannot have one, rather than silently getting one lesson. + $this->availability->shouldReceive('claim')->never(); + $this->bookings->shouldReceive('insert')->never(); + $this->bookings->shouldReceive('insertSeries')->never(); + + $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, ''); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('not_weekly', $result->get_error_code()); + } + + public function testInstructorScopeRefusesAnotherInstructorsTime(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); + $this->availability->shouldReceive('claim')->never(); + + // Slot belongs to instructor 9; My Lessons is scoped to instructor 4. + $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('invalid_slot', $result->get_error_code()); + } + + public function testAnAlreadyBookedTimeIsRefused(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true)); + $this->availability->shouldReceive('claim')->never(); + + $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('slot_taken', $result->get_error_code()); + } + + public function testSomeoneWhoCannotBookLessonsIsRefused(): void + { + Functions\when('user_can')->justReturn(false); + $this->availability->shouldReceive('findById')->never(); + + $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('invalid_student', $result->get_error_code()); + } + + public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3)); + $this->availability->shouldReceive('claim')->never(); + + $result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, ''); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('offering_mismatch', $result->get_error_code()); + } + + public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3)); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + $this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Lesson $l): bool => 3 === $l->offeringId + ))->andReturn(100); + $this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment()); + + self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '')); + } + + public function testAGeneralTimeNeedsALessonTypeChosen(): void + { + $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot()); + $this->availability->shouldReceive('claim')->never(); + + $result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('offering_required', $result->get_error_code()); + } + + public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void + { + $this->availability->shouldReceive('findAvailable') + ->once() + ->with(9, 0, 0, '', '2026-07-27 10:00:00') + ->andReturn([$this->slot(recurrenceGroup: 55)]); + $this->offerings->shouldReceive('findAll') + ->once() + ->with(9, Offering::KIND_PRIVATE_LESSON, true) + ->andReturn([$this->offering()]); + + $data = $this->admin->formData(9); + + self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']); + self::assertSame(1, count($data['slots'])); + self::assertTrue($data['slots'][0]['weekly']); + self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']); + self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']); + } + + public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void + { + $this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]); + $this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]); + $this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering()); + Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe')); + + $data = $this->admin->formData(0); + + self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']); + self::assertStringContainsString('30 min piano', $data['slots'][0]['label']); + } + + private function slot( + int $id = 7, + string $startDt = '2026-07-01 10:00:00', + bool $isBooked = false, + ?int $offeringId = null, + ?int $recurrenceGroup = null + ): AvailabilitySlot { + return new AvailabilitySlot( + instructorId: 9, + startDt: $startDt, + endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800), + durationMinutes: 30, + offeringId: $offeringId, + isBooked: $isBooked, + recurrenceGroup: $recurrenceGroup, + id: $id, + ); + } + + private function offering(): Offering + { + return new Offering( + instructorId: 9, + kind: Offering::KIND_PRIVATE_LESSON, + title: '30 min piano', + price: 40.0, + durationMinutes: 30, + isActive: true, + id: 3, + ); + } + + private function pendingPayment(): Payment + { + return new Payment( + studentId: 42, + instructorId: 9, + registrationType: Payment::REG_LESSON, + registrationId: 100, + amount: 40.0, + status: Payment::STATUS_PENDING, + id: 500, + ); + } + + private function user(int $id, string $name): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->ID = $id; + $user->first_name = ''; + $user->last_name = ''; + $user->nickname = $name; + $user->display_name = $name; + $user->user_login = 'jane'; + $user->user_email = 'jane@example.test'; + + return $user; + } +} diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index f8f5669..e1ac263 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\Booking\Lesson; +use Unsupervised\Schedular\Booking\LessonBooker; use Unsupervised\Schedular\GroupClass\SessionSchedule; use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Offering\Offering; @@ -76,6 +77,10 @@ class BookingEndpointTest extends TestCase $this->offerings, $this->gate, $this->payments, + // The real booker over the same mocked repositories: these tests are + // about what a booking does end to end, and the booker is where most + // of that now lives. + new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians), new CancellationPolicy($this->settings), $this->guardians, $this->sessions, diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index e356d69..5cba5b6 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -36,9 +36,11 @@ class BookingRepositoryTest extends TestCase && $data['student_id'] === 5 && $data['offering_id'] === 7 && $data['recurrence'] === Lesson::RECURRENCE_SINGLE - && $data['status'] === Lesson::STATUS_PENDING; + && $data['status'] === Lesson::STATUS_PENDING + // Booked through the student-facing flow: no staff booker. + && $data['booked_by'] === 0; }), - ['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s'] + ['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s'] ); $this->db->insert_id = 77; diff --git a/tests/Unit/Booking/LessonControllerTest.php b/tests/Unit/Booking/LessonControllerTest.php index c6db1bb..caea00c 100644 --- a/tests/Unit/Booking/LessonControllerTest.php +++ b/tests/Unit/Booking/LessonControllerTest.php @@ -9,8 +9,11 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\Lesson; +use Unsupervised\Schedular\Booking\LessonBooker; +use Unsupervised\Schedular\Booking\AdminBooking; use Unsupervised\Schedular\Booking\LessonController; -use Unsupervised\Schedular\Booking\LessonDetail; +use Unsupervised\Schedular\Registration\IntakeAudit; +use Unsupervised\Schedular\Registration\IntakeRecording; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Tests\Unit\TestCase; @@ -21,7 +24,9 @@ class LessonControllerTest extends TestCase private PaymentRepository&Mockery\MockInterface $payments; private AvailabilityRepository&Mockery\MockInterface $availability; private OfferingRepository&Mockery\MockInterface $offerings; - private LessonDetail&Mockery\MockInterface $detail; + private IntakeAudit&Mockery\MockInterface $detail; + private AdminBooking&Mockery\MockInterface $adminBooking; + private IntakeRecording&Mockery\MockInterface $intake; private LessonController $controller; protected function setUp(): void @@ -32,8 +37,17 @@ class LessonControllerTest extends TestCase $this->payments = Mockery::mock(PaymentRepository::class); $this->availability = Mockery::mock(AvailabilityRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); - $this->detail = Mockery::mock(LessonDetail::class); - $this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail); + $this->detail = Mockery::mock(IntakeAudit::class); + $this->adminBooking = Mockery::mock(AdminBooking::class); + // The book-for-a-student panel has its own tests; here it is an empty form. + $this->adminBooking->shouldReceive('formData') + ->andReturn(['students' => [], 'offerings' => [], 'slots' => []])->byDefault(); + $this->intake = Mockery::mock(IntakeRecording::class); + // Most lessons here were booked by the student, so nothing is recordable; + // the intake tests set up their own staff-booked lesson. + $this->intake->shouldReceive('pending') + ->andReturn(['questions' => [], 'policies' => []])->byDefault(); + $this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail, $this->adminBooking, $this->intake); $_POST = []; $_GET = []; @@ -50,6 +64,17 @@ class LessonControllerTest extends TestCase Functions\when('current_time')->justReturn('2026-07-06'); Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path); Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value); + Functions\when('wp_nonce_field')->justReturn(''); + } + + protected function tearDown(): void + { + // The form-post tests fill $_POST; left behind it makes every later test + // in the suite look like a form submission. + $_POST = []; + $_GET = []; + + parent::tearDown(); } public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void @@ -236,10 +261,10 @@ class LessonControllerTest extends TestCase // The lesson itself is handed over, so the presenter can follow a series // occurrence back to the anchor its answers and acceptances hang off. $this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([ - ['question' => 'Skill level', 'answer' => 'Beginner'], + ['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'], ]); $this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([ - ['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'], + ['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', 'source' => 'Given online when booking'], ]); // The list of lessons must never be queried when routing to a detail view. @@ -274,6 +299,215 @@ class LessonControllerTest extends TestCase self::assertStringNotContainsString('Skill level', $html); } + public function testTheBookForAStudentPanelOffersTheOpenTimesAndStudents(): void + { + $this->adminBooking->shouldReceive('formData')->once()->with(0)->andReturn([ + 'students' => [['id' => 42, 'name' => 'Ada Lovelace']], + 'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']], + 'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]], + ]); + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]); + + $html = $this->render(); + + self::assertStringContainsString('Book a lesson for a student', $html); + self::assertStringContainsString('Ada Lovelace', $html); + self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $html); + self::assertStringContainsString('name="usc_action" value="book_for_student"', $html); + } + + public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void + { + $this->postBooking(); + + // Scope 0: the studio Scheduler may book any instructor's open time. + $this->adminBooking->shouldReceive('book') + ->once() + ->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 0) + ->andReturn('Booked Ada Lovelace into 30 min piano.'); + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]); + + $html = $this->render(); + + self::assertStringContainsString('Booked Ada Lovelace into 30 min piano.', $html); + self::assertStringContainsString('notice-success', $html); + self::assertStringNotContainsString(' open>', $html); + } + + public function testAnInstructorBooksOnlyAgainstTheirOwnTimes(): void + { + $this->postBooking(); + Functions\when('get_current_user_id')->justReturn(9); + + // Scope 9: My Lessons must not reach another instructor's schedule. + $this->adminBooking->shouldReceive('book') + ->once() + ->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 9) + ->andReturn('Booked.'); + $this->adminBooking->shouldReceive('formData')->once()->with(9)->andReturn( + ['students' => [], 'offerings' => [], 'slots' => []] + ); + $this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(9)->andReturn([]); + + ob_start(); + $this->controller->renderInstructorLessons(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString('Booked.', $html); + } + + public function testARefusedBookingShowsWhyAndReopensTheForm(): void + { + $this->postBooking(); + + $this->adminBooking->shouldReceive('book')->once() + ->andReturn(new \WP_Error('slot_taken', 'That time has already been booked.')); + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]); + + $html = $this->render(); + + self::assertStringContainsString('That time has already been booked.', $html); + self::assertStringContainsString('notice-error', $html); + // The panel is a collapsed
    ; an error opens it so the message is + // not hidden behind the summary. + self::assertStringContainsString(' open>', $html); + } + + /** Fill $_POST as the book-for-a-student form does. */ + private function postBooking(): void + { + $_POST = [ + 'usc_action' => 'book_for_student', + 'student_id' => '42', + 'slot_id' => '7', + 'offering_id' => '3', + 'recurrence_weekly' => '1', + 'no_charge' => '1', + 'notes' => 'Make-up lesson', + ]; + + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('absint')->alias(static fn ($v): int => abs((int) $v)); + } + + public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void + { + $_GET['lesson_id'] = '1'; + Functions\when('wp_nonce_field')->justReturn(''); + + // booked_by 7: the studio booked this one, so its intake can be recorded. + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1); + $this->expectDetail($lesson); + + $this->intake->shouldReceive('pending')->once()->with($lesson)->andReturn([ + 'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => true]], + 'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']], + ]); + + $html = $this->render(); + + self::assertStringContainsString('Record intake collected elsewhere', $html); + self::assertStringContainsString('Anything we should know?', $html); + self::assertStringContainsString('Cancellation', $html); + self::assertStringContainsString('How were these collected?', $html); + self::assertStringContainsString('On a signed paper form', $html); + } + + public function testALessonTheStudentBookedOffersNoRecordingForm(): void + { + $_GET['lesson_id'] = '1'; + + // booked_by 0: the student booked it and gave their own answers. + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1); + $this->expectDetail($lesson); + + // Not even asked what is outstanding — the form is not on offer at all. + $this->intake->shouldNotReceive('pending'); + + self::assertStringNotContainsString('Record intake collected elsewhere', $this->render()); + } + + public function testSubmittedIntakeIsRecordedAndReported(): void + { + $_GET['lesson_id'] = '1'; + $_POST = [ + 'usc_action' => 'record_intake', + 'answers' => ['9' => 'Nut allergy'], + 'accepted_policy_version_ids' => ['6'], + 'collected_via' => 'paper', + 'collected_note' => 'Filed in the studio binder', + ]; + + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('absint')->alias(static fn ($v): int => abs((int) $v)); + Functions\when('sanitize_textarea_field')->returnArg(); + Functions\when('get_current_user_id')->justReturn(7); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1); + $this->expectDetail($lesson); + $this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]); + + $this->intake->shouldReceive('record') + ->once() + ->with($lesson, [9 => 'Nut allergy'], [6], 'paper', 'Filed in the studio binder', 7) + ->andReturn('Recorded 1 answer and 1 policy acceptance, collected: On a signed paper form'); + + $html = $this->render(); + + self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html); + self::assertStringContainsString('notice-success', $html); + // Nothing left outstanding, so the form gives way to a plain statement. + self::assertStringContainsString('Everything has been recorded for this booking.', $html); + } + + public function testARefusedRecordingSaysWhy(): void + { + $_GET['lesson_id'] = '1'; + $_POST = [ + 'usc_action' => 'record_intake', + 'answers' => ['9' => 'Nut allergy'], + 'collected_via' => 'other', + ]; + + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('absint')->alias(static fn ($v): int => abs((int) $v)); + Functions\when('sanitize_textarea_field')->returnArg(); + Functions\when('get_current_user_id')->justReturn(7); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1); + $this->expectDetail($lesson); + $this->intake->shouldReceive('pending')->andReturn([ + 'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]], + 'policies' => [], + ]); + $this->intake->shouldReceive('record')->once() + ->andReturn(new \WP_Error('collection_note_required', 'Say how these were collected.')); + + $html = $this->render(); + + self::assertStringContainsString('Say how these were collected.', $html); + self::assertStringContainsString('notice-error', $html); + } + + /** The lookups the detail view makes for one lesson, with an empty audit trail. */ + private function expectDetail(Lesson $lesson): void + { + $slot = new AvailabilitySlot( + instructorId: 3, + startDt: '2026-07-06 09:00:00', + endDt: '2026-07-06 10:00:00', + id: 10 + ); + + $this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson); + $this->availability->shouldReceive('findById')->with(10)->andReturn($slot); + $this->offerings->shouldReceive('findById')->with(8)->andReturn(null); + $this->detail->shouldReceive('answers')->with($lesson)->andReturn([]); + $this->detail->shouldReceive('acceptances')->with($lesson)->andReturn([]); + } + private function render(): string { ob_start(); diff --git a/tests/Unit/Booking/LessonTest.php b/tests/Unit/Booking/LessonTest.php index 1c5cfb0..9928be5 100644 --- a/tests/Unit/Booking/LessonTest.php +++ b/tests/Unit/Booking/LessonTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Tests\Unit\Booking; use Unsupervised\Schedular\Booking\Lesson; +use Unsupervised\Schedular\Registration\Answer; use Unsupervised\Schedular\Tests\Unit\TestCase; class LessonTest extends TestCase @@ -76,4 +77,21 @@ class LessonTest extends TestCase self::assertArrayHasKey($key, $arr); } } + + public function testAWeeklySeriesSharesOneIntakeRegistration(): void + { + // Occurrence 12 of a series anchored on lesson 7: answered for once. + $occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 7, id: 12); + + self::assertSame(Answer::REG_LESSON, $occurrence->intakeRegistrationType()); + self::assertSame(7, $occurrence->intakeRegistrationId()); + // A single lesson is its own registration. + self::assertSame(12, (new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->intakeRegistrationId()); + } + + public function testOnlyAStudioBookedLessonIsStaffRegistered(): void + { + self::assertFalse((new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->isStaffRegistered()); + self::assertTrue((new Lesson(slotId: 10, studentId: 5, instructorId: 3, bookedBy: 9, id: 12))->isStaffRegistered()); + } } diff --git a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php index efba693..e612948 100644 --- a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php +++ b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php @@ -35,9 +35,11 @@ class EnrollmentRepositoryTest extends TestCase return $d['offering_id'] === 7 && $d['student_id'] === 5 && $d['instructor_id'] === 3 - && $d['status'] === Enrollment::STATUS_ACTIVE; + && $d['status'] === Enrollment::STATUS_ACTIVE + // Enrolled through the student-facing flow: no staff enroller. + && $d['enrolled_by'] === 0; }), - ['%d', '%d', '%d', '%s', '%d', '%s'] + ['%d', '%d', '%d', '%s', '%d', '%d', '%s'] ); $this->db->insert_id = 12; diff --git a/tests/Unit/GroupClass/EnrollmentTest.php b/tests/Unit/GroupClass/EnrollmentTest.php index 06cf5db..8870b3e 100644 --- a/tests/Unit/GroupClass/EnrollmentTest.php +++ b/tests/Unit/GroupClass/EnrollmentTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Tests\Unit\GroupClass; use Unsupervised\Schedular\GroupClass\Enrollment; +use Unsupervised\Schedular\Registration\Answer; use Unsupervised\Schedular\Tests\Unit\TestCase; class EnrollmentTest extends TestCase @@ -45,8 +46,25 @@ class EnrollmentTest extends TestCase { $arr = (new Enrollment(7, 5, 3, id: 12))->toArray(); - foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) { + foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id', 'enrolled_by'] as $key) { self::assertArrayHasKey($key, $arr); } } + + public function testAnEnrolmentIsItsOwnIntakeRegistration(): void + { + $enrollment = new Enrollment(7, 5, 3, id: 12); + + self::assertSame(Answer::REG_ENROLLMENT, $enrollment->intakeRegistrationType()); + // No series anchor to follow: a term of classes is one enrolment. + self::assertSame(12, $enrollment->intakeRegistrationId()); + self::assertSame(7, $enrollment->intakeOfferingId()); + self::assertSame(5, $enrollment->intakeStudentId()); + } + + public function testOnlyAStudioMadeEnrolmentIsStaffRegistered(): void + { + self::assertFalse((new Enrollment(7, 5, 3, id: 12))->isStaffRegistered()); + self::assertTrue((new Enrollment(7, 5, 3, enrolledBy: 9, id: 12))->isStaffRegistered()); + } } diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php index b38c6d7..ec34a57 100644 --- a/tests/Unit/GroupClass/GroupClassControllerTest.php +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -16,6 +16,8 @@ use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Registration\IntakeAudit; +use Unsupervised\Schedular\Registration\IntakeRecording; use Unsupervised\Schedular\Tests\Unit\TestCase; class GroupClassControllerTest extends TestCase @@ -27,6 +29,8 @@ class GroupClassControllerTest extends TestCase private PaymentService&Mockery\MockInterface $paymentService; private InviteRepository&Mockery\MockInterface $invites; private RegistrationMailer&Mockery\MockInterface $mailer; + private IntakeAudit&Mockery\MockInterface $audit; + private IntakeRecording&Mockery\MockInterface $intake; private GroupClassController $controller; protected function setUp(): void @@ -40,6 +44,8 @@ class GroupClassControllerTest extends TestCase $this->paymentService = Mockery::mock(PaymentService::class); $this->invites = Mockery::mock(InviteRepository::class); $this->mailer = Mockery::mock(RegistrationMailer::class); + $this->audit = Mockery::mock(IntakeAudit::class); + $this->intake = Mockery::mock(IntakeRecording::class); $this->controller = new GroupClassController( $this->enrollments, $this->offerings, @@ -48,6 +54,8 @@ class GroupClassControllerTest extends TestCase $this->paymentService, $this->invites, $this->mailer, + $this->audit, + $this->intake, ); Functions\when('current_user_can')->justReturn(true); @@ -411,7 +419,11 @@ class GroupClassControllerTest extends TestCase $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0)); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); - $this->enrollments->shouldReceive('insert')->once()->andReturn(44); + // Stamped with the staff member who added them (user 3), which is what + // later lets the studio record the intake it never had a chance to ask for. + $this->enrollments->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Enrollment $e): bool => 3 === $e->enrolledBy && $e->isStaffRegistered() + ))->andReturn(44); $payment = new Payment( studentId: 5, @@ -435,6 +447,103 @@ class GroupClassControllerTest extends TestCase self::assertStringContainsString('1 student(s) added to the class.', $html); } + public function testEnrollmentIdOpensTheIntakeDetailView(): void + { + $_GET = ['enrollment_id' => '44']; + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); + + // enrolled_by 3: the studio added this student, so intake can be recorded. + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44); + $this->expectEnrollmentDetail($enrollment); + + $this->intake->shouldReceive('pending')->once()->with($enrollment)->andReturn([ + 'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]], + 'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']], + ]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('Enrolment details', $html); + self::assertStringContainsString('Ada Lovelace', $html); + self::assertStringContainsString('Record intake collected elsewhere', $html); + self::assertStringContainsString('Anything we should know?', $html); + self::assertStringContainsString('How were these collected?', $html); + } + + public function testAnEnrolmentTheStudentMadeOffersNoRecordingForm(): void + { + $_GET = ['enrollment_id' => '44']; + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); + + // enrolled_by 0: the student enrolled themselves and gave their own answers. + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44); + $this->expectEnrollmentDetail($enrollment); + + $this->intake->shouldNotReceive('pending'); + + self::assertStringNotContainsString('Record intake collected elsewhere', $this->renderInstructor()); + } + + public function testAnInstructorCannotOpenAnotherInstructorsEnrolment(): void + { + $_GET = ['enrollment_id' => '44']; + + // Enrolment belongs to instructor 9; the current user is 3. + $this->enrollments->shouldReceive('findById')->once()->with(44) + ->andReturn(new Enrollment(offeringId: 8, studentId: 5, instructorId: 9, enrolledBy: 9, id: 44)); + $this->audit->shouldNotReceive('answers'); + $this->intake->shouldNotReceive('pending'); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('This enrolment could not be found.', $html); + self::assertStringNotContainsString('Record intake collected elsewhere', $html); + } + + public function testSubmittedEnrolmentIntakeIsRecordedAndReported(): void + { + $_GET = ['enrollment_id' => '44']; + $_POST = [ + 'usc_action' => 'record_intake', + 'answers' => ['9' => 'Nut allergy'], + 'accepted_policy_version_ids' => ['6'], + 'collected_via' => 'phone', + 'collected_note' => 'Called the parent', + ]; + + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('sanitize_textarea_field')->returnArg(); + Functions\when('wp_unslash')->returnArg(); + + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44); + $this->expectEnrollmentDetail($enrollment); + $this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]); + + $this->intake->shouldReceive('record') + ->once() + ->with($enrollment, [9 => 'Nut allergy'], [6], 'phone', 'Called the parent', 3) + ->andReturn('Recorded 1 answer and 1 policy acceptance, collected: Over the phone'); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html); + self::assertStringContainsString('notice-success', $html); + // The generic class-form handler must not also run and report a missing class. + self::assertStringNotContainsString('That group class was not found.', $html); + } + + /** The lookups the enrolment detail view makes, with an empty audit trail. */ + private function expectEnrollmentDetail(Enrollment $enrollment): void + { + $this->enrollments->shouldReceive('findById')->once()->with(44)->andReturn($enrollment); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(8, 'Choir', 10)); + $this->audit->shouldReceive('answers')->with($enrollment)->andReturn([]); + $this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]); + } + public function testGrantAccessCreatesGrantAndEmailsStudent(): void { $_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]]; diff --git a/tests/Unit/Policy/AcceptanceRepositoryTest.php b/tests/Unit/Policy/AcceptanceRepositoryTest.php index c0bc48d..9bbc449 100644 --- a/tests/Unit/Policy/AcceptanceRepositoryTest.php +++ b/tests/Unit/Policy/AcceptanceRepositoryTest.php @@ -38,9 +38,13 @@ class AcceptanceRepositoryTest extends TestCase && $d['registration_id'] === 12 // No explicit acceptor: the student agreed for themselves. && $d['accepted_by'] === 5 - && $d['ip_address'] === '203.0.113.7'; + && $d['ip_address'] === '203.0.113.7' + // Ticked online: no collection provenance to record. + && null === $d['collected_via'] + && null === $d['collected_note'] + && 0 === $d['recorded_by']; }), - ['%d', '%d', '%d', '%s', '%d', '%s', '%s'] + ['%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s'] ); $this->db->insert_id = 1; diff --git a/tests/Unit/Registration/AnswerRepositoryTest.php b/tests/Unit/Registration/AnswerRepositoryTest.php index 7c6df95..732ef21 100644 --- a/tests/Unit/Registration/AnswerRepositoryTest.php +++ b/tests/Unit/Registration/AnswerRepositoryTest.php @@ -36,9 +36,13 @@ class AnswerRepositoryTest extends TestCase && $data['registration_type'] === Answer::REG_LESSON && $data['registration_id'] === 12 && $data['student_id'] === 5 - && $data['answer_value'] === 'Beginner'; + && $data['answer_value'] === 'Beginner' + // Given online: no collection provenance to record. + && null === $data['collected_via'] + && null === $data['collected_note'] + && 0 === $data['recorded_by']; }), - ['%d', '%s', '%d', '%d', '%s', '%s'] + ['%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s'] ); $this->db->insert_id = 77; diff --git a/tests/Unit/Registration/AnswerTest.php b/tests/Unit/Registration/AnswerTest.php index a4e941c..eb0a955 100644 --- a/tests/Unit/Registration/AnswerTest.php +++ b/tests/Unit/Registration/AnswerTest.php @@ -10,7 +10,7 @@ class AnswerTest extends TestCase { public function testConstructorAndProperties(): void { - $answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', 99); + $answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', id: 99); self::assertSame(3, $answer->questionId); self::assertSame(Answer::REG_LESSON, $answer->registrationType); diff --git a/tests/Unit/Booking/LessonDetailTest.php b/tests/Unit/Registration/IntakeAuditTest.php similarity index 74% rename from tests/Unit/Booking/LessonDetailTest.php rename to tests/Unit/Registration/IntakeAuditTest.php index f198c49..7c0f267 100644 --- a/tests/Unit/Booking/LessonDetailTest.php +++ b/tests/Unit/Registration/IntakeAuditTest.php @@ -1,11 +1,11 @@ policies = Mockery::mock(PolicyRepository::class); $this->versions = Mockery::mock(PolicyVersionRepository::class); - $this->detail = new LessonDetail( + $this->detail = new IntakeAudit( $this->answers, $this->questions, $this->acceptances, @@ -58,8 +60,8 @@ class LessonDetailTest extends TestCase self::assertSame( [ - ['question' => 'Skill level', 'answer' => 'Beginner'], - ['question' => '#9', 'answer' => '—'], + ['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'], + ['question' => '#9', 'answer' => '—', 'source' => 'Given online when booking'], ], $this->detail->answers($this->lesson(7)) ); @@ -88,12 +90,47 @@ class LessonDetailTest extends TestCase 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', + 'source' => 'Given online when booking', ], ], $this->detail->acceptances($this->lesson(7)) ); } + public function testACollectedElsewhereAnswerNamesItsSourceAndWhoRecordedIt(): void + { + $user = Mockery::mock(\WP_User::class); + $user->ID = 7; + $user->first_name = 'Jane'; + $user->last_name = 'Doe'; + $user->nickname = 'jane'; + $user->display_name = 'jane'; + Functions\when('get_userdata')->justReturn($user); + + $this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([ + new Answer( + questionId: 2, + registrationType: Answer::REG_LESSON, + registrationId: 7, + studentId: 5, + answerValue: 'Nut allergy', + collectedVia: IntakeProvenance::VIA_PAPER, + collectedNote: 'Filed in the studio binder', + recordedBy: 7 + ), + ]); + $this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Allergies', id: 2)); + + self::assertSame( + [[ + 'question' => 'Allergies', + 'answer' => 'Nut allergy', + 'source' => 'On a signed paper form — Filed in the studio binder — recorded by Jane Doe', + ]], + $this->detail->answers($this->lesson(7)) + ); + } + public function testSeriesOccurrenceReadsTheAnchorsAnswersAndAcceptances(): void { // Occurrence #12 of a weekly reservation anchored on lesson 7: the intake @@ -119,7 +156,7 @@ class LessonDetailTest extends TestCase $this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3)); self::assertSame( - [['question' => 'Skill level', 'answer' => 'Beginner']], + [['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking']], $this->detail->answers($occurrence) ); self::assertSame( @@ -128,6 +165,7 @@ class LessonDetailTest extends TestCase 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', + 'source' => 'Given online when booking', ]], $this->detail->acceptances($occurrence) ); diff --git a/tests/Unit/Registration/IntakeRecordingTest.php b/tests/Unit/Registration/IntakeRecordingTest.php new file mode 100644 index 0000000..4e9fb8b --- /dev/null +++ b/tests/Unit/Registration/IntakeRecordingTest.php @@ -0,0 +1,255 @@ +alias(static fn ($v): int => abs((int) $v)); + + $this->questions = Mockery::mock(QuestionRepository::class); + $this->answers = Mockery::mock(AnswerRepository::class); + $this->policies = Mockery::mock(PolicyRepository::class); + $this->versions = Mockery::mock(PolicyVersionRepository::class); + $this->acceptances = Mockery::mock(AcceptanceRepository::class); + $this->gate = Mockery::mock(RegistrationGate::class); + + $this->intake = new IntakeRecording( + $this->questions, + $this->answers, + $this->policies, + $this->versions, + $this->acceptances, + $this->gate + ); + } + + public function testPendingListsOnlyWhatIsNotYetRecorded(): void + { + $this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([ + new Question(offeringId: 8, label: 'Skill level', isRequired: true, id: 2), + new Question(offeringId: 8, label: 'Anything we should know?', id: 9), + ]); + // Question 2 was already answered; only question 9 is still outstanding. + $this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([ + new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'), + ]); + + $this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]); + $this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([ + new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1), + ]); + $this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6)); + $this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3)); + + $pending = $this->intake->pending($this->lesson()); + + self::assertSame([['id' => 9, 'label' => 'Anything we should know?', 'required' => false]], $pending['questions']); + self::assertSame([['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']], $pending['policies']); + } + + public function testRecordsTheAnswersAndAcceptancesWithHowTheyWereCollected(): void + { + $this->expectPending(); + + $this->gate->shouldReceive('record') + ->once() + ->with( + PolicyAcceptance::REG_LESSON, + 1, + 5, + 8, + [9 => 'Nut allergy'], + [6], + // No IP: the student was never at a browser, and the staff member's + // would be a false location in the audit trail. + null, + // No acceptor override either: the student agreed, on paper. + 0, + Mockery::on(static fn (IntakeProvenance $p): bool => IntakeProvenance::VIA_PAPER === $p->collectedVia + && 'Filed in the studio binder' === $p->collectedNote + && 3 === $p->recordedBy) + ); + + $notice = $this->intake->record( + $this->lesson(), + [9 => 'Nut allergy'], + [6], + IntakeProvenance::VIA_PAPER, + 'Filed in the studio binder', + 3 + ); + + self::assertIsString($notice); + self::assertStringContainsString('1 answer', $notice); + self::assertStringContainsString('1 policy acceptance', $notice); + self::assertStringContainsString('On a signed paper form', $notice); + self::assertStringContainsString('Filed in the studio binder', $notice); + } + + public function testALessonTheStudentBookedThemselvesCannotBeRecordedAgainst(): void + { + // No repository is even consulted: the guard comes first, so a student's + // own answers can never be added to after the fact. + $this->gate->shouldReceive('record')->never(); + + $result = $this->intake->record( + $this->lesson(bookedBy: 0), + [9 => 'Nut allergy'], + [], + IntakeProvenance::VIA_PAPER, + '', + 3 + ); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('not_recordable', $result->get_error_code()); + } + + public function testAnAlreadyRecordedAnswerOrAcceptanceIsIgnored(): void + { + $this->expectPending(); + $this->gate->shouldReceive('record')->never(); + + // Question 2 and version 4 are already on file — a stale form reposting + // them must not duplicate or overwrite what is there. + $result = $this->intake->record( + $this->lesson(), + [2 => 'Advanced'], + [4], + IntakeProvenance::VIA_PAPER, + '', + 3 + ); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('nothing_to_record', $result->get_error_code()); + } + + public function testTheCollectionMethodIsRequiredAndMustBeOneOfTheKnownOnes(): void + { + $this->gate->shouldReceive('record')->never(); + + $result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], 'telepathy', '', 3); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('invalid_collection_method', $result->get_error_code()); + } + + public function testOtherMustBeExplained(): void + { + $this->gate->shouldReceive('record')->never(); + + $result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], IntakeProvenance::VIA_OTHER, ' ', 3); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('collection_note_required', $result->get_error_code()); + } + + public function testAWeeklySeriesRecordsAgainstItsAnchor(): void + { + // Occurrence #12 of a series anchored on lesson 1: answered for once. + $occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 1, bookedBy: 3, id: 12); + + $this->expectPending(); + + // Registration id 1, not 12: the whole series shares one intake record, so + // opening any occurrence shows and adds to the same answers. + $this->gate->shouldReceive('record') + ->once() + ->with(PolicyAcceptance::REG_LESSON, 1, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any()); + + self::assertIsString($this->intake->record($occurrence, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_PHONE, '', 3)); + } + + public function testAGroupClassEnrolmentRecordsAgainstTheEnrolmentTable(): void + { + // The same recorder, a different registration type: an enrolment is its own + // registration, so nothing follows a series anchor here. + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 7, id: 44); + + $this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([ + new Question(offeringId: 8, label: 'Anything we should know?', id: 9), + ]); + $this->answers->shouldReceive('findByRegistration')->with(Answer::REG_ENROLLMENT, 44)->andReturn([]); + $this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([]); + $this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_ENROLLMENT, 44)->andReturn([]); + + $this->gate->shouldReceive('record') + ->once() + ->with(Answer::REG_ENROLLMENT, 44, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any()); + + self::assertIsString($this->intake->record($enrollment, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_EMAIL, '', 7)); + } + + public function testAnEnrolmentTheStudentMadeCannotBeRecordedAgainst(): void + { + $this->gate->shouldReceive('record')->never(); + + $result = $this->intake->record( + new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44), + [9 => 'Nut allergy'], + [], + IntakeProvenance::VIA_EMAIL, + '', + 7 + ); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('not_recordable', $result->get_error_code()); + } + + /** The repository responses behind a lesson with question 9 and version 6 outstanding. */ + private function expectPending(): void + { + $this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([ + new Question(offeringId: 8, label: 'Skill level', id: 2), + new Question(offeringId: 8, label: 'Anything we should know?', id: 9), + ]); + $this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([ + new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'), + ]); + $this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]); + $this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([ + new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1), + ]); + $this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6)); + $this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3)); + } + + private function lesson(int $bookedBy = 3): Lesson + { + return new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: $bookedBy, id: 1); + } +} -- 2.54.0