Files
unsupervised-scheduler/docs/features/lesson-booking.md
T
thatguygriffandClaude Opus 5 4a41ba96fb
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
Let parents register once and book for their children
A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:00:34 -03:00

14 KiB
Raw Blame History

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
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 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.

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.

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 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.

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)
  • 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
  • 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 divs 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/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

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.