Files
unsupervised-scheduler/docs/features/lesson-booking.md
T
thatguygriffandClaude Fable 5 ff059909a5
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Charge weekly reservations for every claimed occurrence and confirm the whole series
A weekly booking on a per-lesson (one_time) priced offering was creating its
single upfront payment for one week's price while reserving up to 12 weeks,
and settling that payment confirmed only the anchor lesson, leaving the rest
of the series pending forever.

- BookingEndpoint now charges price x claimed occurrences for one_time
  billing; a full_term price is still charged once since it covers the term.
- PaymentService::confirmRegistration resolves the anchor lesson's series and
  confirms every non-cancelled row via the new
  BookingRepository::updateStatusForSeries().

Closes #79

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:19:53 -03:00

7.6 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 an agenda list or a weekly calendar (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
  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. 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. Payment is taken per the student's billing method (card by default; pending for e-transfer; skipped for comp). See payments.md.
  7. 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.
  8. On successful payment (or comp) the lesson is confirmed and a receipt is emailed.
  9. Instructor sees the booking under My Lessons and may update status via PATCH /bookings/{id}/status.
  10. The booking page also shows the student their upcoming lessons (GET /bookings) with a per-lesson status badge (pending payment / confirmed) and a Cancel button.

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

Frontend Shortcodes

  • [us_booking] — student calendar + registration flow; requires book_lesson capability
  • [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
  • REST endpoint: Unsupervised\Schedular\Booking\BookingEndpoint
  • Frontend: Unsupervised\Schedular\Booking\BookingPage, Unsupervised\Schedular\Auth\LoginPage

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.

Tests

  • tests/Unit/Booking/BookingRepositoryTest.php
  • tests/Unit/Booking/LessonTest.php