# Feature: Lesson Booking ## Overview Students register for a private lesson by choosing an offering, picking a time (or reserving a weekly slot for the term), answering the offering's intake questions, accepting current policies, and paying. Instructors confirm or cancel from wp-admin or via the REST API. A lesson becomes `confirmed` only once its payment is `paid` (or the student is comp'd). ## Data Model — `{prefix}us_lessons` | Column | Type | Notes | |----------------|------------------|-------------------------------------------------------------| | `id` | BIGINT UNSIGNED | Primary key | | `slot_id` | BIGINT UNSIGNED | FK → `us_availability.id` | | `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (the private-lesson type booked) | | `student_id` | BIGINT UNSIGNED | WordPress user ID | | `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised for fast queries) | | `recurrence` | VARCHAR(10) | `single` or `weekly` | | `series_id` | BIGINT UNSIGNED | Nullable — groups the lesson rows of one weekly reservation | | `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 1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **Show Only** button beside the view toggle opens a lesson-type filter that narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**). 2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. 3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term. 4. Student answers the offering's questions (`GET /offerings/{id}/questions`). 5. Student accepts the current published policy versions (`GET /policies`) — required to continue. 6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`. 7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`. 8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately. 9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed. 10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. 11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments. 12. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request. ## Lesson-Type Filter Not every open slot can be booked as every private-lesson type — a slot tied to an offering takes that offering only, and a generic slot only takes types whose length fits. The booking calendar therefore carries a lesson-type filter, collapsed behind a **Show Only** button that sits in the calendar's control row beside the List/Week toggle. Opening it reveals the type list between that row and the calendar: a checkbox per active private-lesson type (from `GET /offerings?kind=private_lesson`, fetched once per page load), showing the instructor's name alongside the title when the catalog spans more than one instructor. The button carries the number of ticked types and stays highlighted while the filter is on, so a collapsed filter is never invisible. Both button and list are hidden when there is only one bookable type. Ticking one or more types narrows the calendar to the slots bookable as one of them; no ticks means no filter, and collapsing the list leaves the filter applied. Picking a filtered slot narrows the registration form's **Lesson type** picker the same way, and when exactly one type remains it is pre-selected and its intake questions load immediately. Changing the filter re-anchors the week view on the earliest matching slot, so the student never lands on an empty week. **Show all types** clears the filter. Bookability is decided client-side by `offeringFitsSlot()` in `assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same instructor, the tied offering when there is one, otherwise a matching `duration_minutes`). The filter is a browsing aid only: the server re-checks every booking regardless. Two block/shortcode options change what the filter has to work with (see `editor-blocks.md`), passed to the script as data attributes on `#us-booking-app`: - **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one offering, so the page lists only the times bookable as it and books nothing else — the filter control hides itself, there being one type left. A pinned type that is no longer offered shows "This lesson type is not available for booking right now" rather than an empty calendar. - **Filter off** (`data-type-filter="0"`) drops the **Show Only** button entirely; every open time is listed, as before the filter existed. ## Embedding Halves of the Page The page has two halves — the booking calendar and the student's upcoming lessons — and the block/shortcode can embed either on its own (`displayMode` / `show`: `both` (default), `booking`, `upcoming`). The template simply omits the containers of the half that is not wanted, and the script skips the work that belongs to a missing container: an upcoming-only embed never requests 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 and every field as it was submitted, so only the mistake needs correcting. A booking that succeeds clears the form, so the next one does not inherit it. 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 four 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. 4. **It can book for a student who cannot book at all.** The guard is `Auth\RoleManager::isStudent()` — the student *role*, not the `book_lesson` capability — so it covers a guardian's child and a self-signup still awaiting approval alike, and is shared with the group-class **Add students directly** and **Make available** controls so the two paths cannot drift. Both hold the role; both have `book_lesson` withheld (`Guardian\ChildLoginGate`, `Auth\RegistrationLoginGate`) so that neither can book in their own name. That restriction is on them, not on the studio acting for them — and for a child, whose account is never signed in to, it is the only route to a lesson besides their guardian's. The picker and the guard therefore accept exactly the same set, so nothing offered in the panel can be refused as ineligible. 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 rebooking, and voids a still-pending payment (marked `failed` so it leaves the admin confirmation queue). A `paid` payment is never touched — refunds are a manual, admin-side decision. Instructors cancelling via `PATCH /bookings/{id}/status` get the same slot release and payment voiding; reinstating a cancelled lesson re-claims its slot and fails with `409 slot_taken` if the freed time was booked by someone else in the meantime. ## Weekly Reservations A weekly reservation creates one `series_id` shared across N lesson rows (one per week in the term) and reserves the matching availability windows. It is billed **upfront as a single payment** linked to the series' first (anchor) lesson: - `billing_mode = full_term` — the offering's price already covers the term and is charged once. - `billing_mode = one_time` — the per-lesson price is charged **once per occurrence actually claimed** (price × N). Settling that payment (Stripe webhook, e-transfer confirmation, comp) confirms **every non-cancelled lesson in the series** (`BookingRepository::updateStatusForSeries()`), not just the anchor row. ## REST API | Method | Endpoint | Permission | |-----------|-------------------------------------------------|--------------------------------| | `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user | | `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` | | `POST` | `/wp-json/us-scheduler/v1/bookings/{id}/cancel` | Logged-in owner of the lesson | | `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin | `POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]` (`question_id` → value), `accepted_policy_version_ids[]`, and payment data (see `payments.md`). The response includes `ids`, the resulting lesson `status`, and `payment` — a `{id, method, status}` summary, or `null` when nothing is owed (the front end then skips the payment step). An offering is always required (`400 offering_required` otherwise): a slot tied to an offering uses that offering regardless of the request, while a generic slot uses the student's `offering_id`, which must be one of the instructor's active `private_lesson` offerings whose `duration_minutes` matches the slot. `GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own for students; the instructor's for callers with `manage_availability`), each with the slot's `start_dt`/`end_dt`. It also returns **upcoming group-class sessions**, sorted in among the lessons by start time (`GroupClass\SessionSchedule`). A student gets every remaining session of every class they are enrolled in; an instructor gets every session of the classes they teach. These rows carry `kind: "group_class"` — a session is a date in a term rather than a booked slot, so `booking.js` labels it and gives it no Cancel button. Lesson rows carry no `kind`, and that absence is what marks them cancellable. 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, 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 `Availability\WeekCalendar`), with the original table available as the **List** view — the list is where the per-lesson HST and e-transfer edit forms live. Both views show the booked offering's name, and each lesson links through (`?lesson_id=`) to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the offering, time, status, notes, the policy versions the student accepted (with acceptance time and IP), and their intake-question answers. On **My Lessons** an instructor may only open their own lessons; the studio **Scheduler** may open any. ## Frontend Shortcodes - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`) - `[us_student_login]` — front-end login form for students ## 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\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. > **Payment seam:** a priced booking is created with `status = pending` and its > payment linked via `payment_id`; the lesson is confirmed when the payment is > settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`. > Unpriced bookings skip the seam entirely and are confirmed at creation. > `GET /policies?scope=booking` returns just the booking-gate policies the form > must collect. > > **Frontend CSS scoping:** every rule for the booking page's own markup is > written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit > inside whatever layout the active theme provides, and bare class selectors lose > to theme rules on `div`/`span`/`strong` — which flattens the flex layout and > renders the lesson details on top of the actions. The row's two columns are > `div`s for the same reason: the layout must not depend on overriding the > 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/BookingEndpointTest.php` ## Booking For Someone Else A guardian books for their children from their own account. `POST /bookings` accepts an optional **`student_id`**, honoured only when `Guardian\GuardianService::canActFor()` confirms the caller is that student's guardian — anything else is a `403`. The booking form's "Who is this for?" picker lists **children first**, so the default selection is never the parent. `GET /bookings` returns the whole household, and a guardian may cancel any of their children's lessons. See `parent-guardian-accounts.md`.