Files
unsupervised-scheduler/docs/features/offerings.md
T
thatguygriffandClaude Opus 5 9344ab7193
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Show price cadence and require a pay agreement at booking
Every price a student meets on the front end now carries the cadence it is
billed on — at booking, up front, weekly, monthly — so a bare amount can no
longer read as a one-off when it is a recurring charge.

Both registration forms then restate the price and require a second, separate
tick agreeing to pay it, distinct from the policy acceptances above it. The
agreed figure includes the studio HST so it matches Payment::total(), the amount
actually billed; the rate reaches the browser as a new localized `taxRate`.

A weekly reservation is charged per lesson for every week it claims, and a week
another student takes first is simply not claimed, so its total is quoted as a
ceiling ("up to 12 lessons") rather than a promise. Free offerings have nothing
to agree to and show no price block at all.

The formatting and the agreement live in one shared helper (`window.usPricing`,
registered as `us-scheduler-pricing`) so a price reads the same in the booking
form, the class catalogue and the editor preview.

Closes #124

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 14:43:38 -03:00

9.9 KiB
Raw Blame History

Feature: Offerings

Overview

An offering is anything a student can register for: a private-lesson type (30 or 60 minutes, single or weekly) or a group class. Offerings carry pricing, billing mode, and the intake questions a registrant must answer. They are the catalog the booking calendar and group-class pages are built from.

Data Model — {prefix}us_offerings

Column Type Notes
id BIGINT UNSIGNED Primary key
instructor_id BIGINT UNSIGNED WordPress user ID of the owning instructor
kind VARCHAR(20) private_lesson or group_class
title VARCHAR(191) Display name
description TEXT Optional longer description
duration_minutes SMALLINT Private lessons only (e.g. 30, 60); NULL for group classes
price DECIMAL(10,2) Price in dollars
currency VARCHAR(3) ISO 4217, e.g. CAD
billing_mode VARCHAR(20) one_time, full_term, weekly, or monthly (see Billing Mode below)
allow_weekly TINYINT(1) Private only — may be reserved weekly for the term
capacity SMALLINT Group only — max enrolments; NULL for private
term_start DATE Group / term offerings — first day; NULL otherwise
term_end DATE Group / term offerings — last day; NULL otherwise
class_time TIME Group only — time of day each session starts; NULL otherwise
enrollment_deadline DATE Group only — last day students may enrol; NULL defaults to term_start (the first class day)
withdrawal_deadline DATE Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely
schedule_note VARCHAR(191) Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"
cancellation_cutoff_hours SMALLINT UNSIGNED Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see cancellation-cutoff.md)
access_mode VARCHAR(20) public (listed in the catalog) or invite_only (group classes hidden from the catalog — see group-classes.md)
is_active TINYINT(1) 0 = hidden from registration, 1 = bookable
created_at DATETIME Insertion time

Billing Mode

  • one_time — charged once at booking (a single private lesson).
  • full_term — charged in full upfront at registration (a weekly private reservation or a year-long group class). See payments.md.
  • weeklynot charged at registration; a pending payment for one lesson's fee is generated 24 hours before each lesson by the daily billing scan.
  • monthlynot charged at registration; on the 1st of each month a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).

Students see the mode as a cadence beside every price on the front end — at booking, up front, weekly, monthly — and confirm it explicitly before a booking or enrolment goes through. See Price Display and the Pay Agreement in payments.md.

weekly and monthly are scheduled billing (Offering::isScheduledBilling()): the booking/enrolment succeeds with no payment step, and payments are created later by the daily us_generate_due_payments cron scan. See scheduled-billing.md and payments.md.

Term Dates

Group classes carry a term: term_start is the date of the first class and term_end the last. The add-offering form takes a start date plus a sessions control — one-off (the term ends the day it starts) or weekly for N sessions (term_end = term_start + (N1) weeks, computed by Offering::weeklyTermEnd()). Dates are validated by Offering::normalizeDate() (strict Y-m-d); an invalid start date leaves both term columns NULL. The student-facing class card shows the date (one-off) or the date range with the weekly session count.

Class Time and Sessions

A group class also carries class_time — the time of day each session starts — validated by Offering::normalizeTime() (strict H:i/H:i:s; garbage leaves it NULL). class_time + term_start/term_end + duration_minutes together define the concrete session windows: Offering::sessionWindows() returns one {start, end} per session (weekly across the term, or a single window for a one-off), and returns an empty list unless date, time, and a positive duration are all set. These windows drive availability reconciliation (see Instructor assignment below and group-classes.md).

Enrolment deadline

A group class carries an optional enrollment_deadline the instructor sets on the offering form (blank leaves it NULL). Offering::effectiveEnrollmentDeadline() resolves it to the stored date, or to term_start (the first class day) when unset, so a class with no explicit deadline still closes to new enrolments once the first class arrives. Offering::isEnrollmentOpen($today) compares a Y-m-d "today" against that effective deadline (inclusive — the deadline day is still open). The enrolment endpoint enforces it (403 enrollment_closed) and the front-end group-class list mirrors the same rule; see group-classes.md.

Withdrawal deadline

A group class also carries an optional withdrawal_deadline — the last day a student may withdraw themselves from the class. Unlike the enrolment deadline it has no implicit default: Offering::isWithdrawalOpen($today) treats an unset (NULL) deadline as always open, so a class only closes to self-withdrawal once the instructor sets a date and it passes (comparison is inclusive — the deadline day is still open). A withdrawal made while open frees the seat and voids any still-pending payment but never issues an account credit (credits are reserved for cancelled lessons; see credits.md). Once the deadline passes the student must contact the studio, and an admin withdraws them by hand from the student detail page — the admin path is never subject to the deadline. The student endpoint enforces it (403 withdrawal_closed) and the front-end group-class list mirrors the rule; see group-classes.md.

Instructor assignment

Every offering has an owning instructor_id. A studio admin (manage_instructors) sees an Instructor picker on the offering form and may assign a group class to any instructor; a plain instructor never sees the picker and always owns the classes they create (the posted value is ignored for them, and updates never reassign the owner otherwise). When a group class is saved with an assigned instructor and a full schedule, Offering\ClassSlotReconciler clears that instructor's open availability slots overlapping each session so students can't book them, and reports any already-booked lesson that clashes as a conflict for the studio to resolve by hand (a booked lesson is never deleted). The result is surfaced as an admin notice after saving.

Admin Interface

Studio admin and instructors manage offerings under Offerings in wp-admin.

  • Studio admin (manage_offerings) manages offerings for any instructor.
  • Instructor (manage_offerings) manages only their own.
  • Each offering's intake questions are edited from the offering screen (see registration-questions.md).
  • The offerings list shows each offering's ID (needed for [us_group_classes offering="…"]) and its term dates.
  • Edit on a row reloads the page (?usc_edit=<id>) with the form prefilled; saving posts usc_action=update. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings.
  • The form includes a description textarea and an Active — open for registration checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST is_active flag).

REST API

Method Endpoint Permission
GET /wp-json/us-scheduler/v1/offerings book_lesson or manage_offerings (active offerings only)
POST /wp-json/us-scheduler/v1/offerings manage_offerings
PATCH /wp-json/us-scheduler/v1/offerings/{id} manage_offerings + owner
DELETE /wp-json/us-scheduler/v1/offerings/{id} manage_offerings + owner

GET supports query params: instructor_id, kind.

Implementation

  • Repository: Unsupervised\Schedular\Offering\OfferingRepository
  • Model: Unsupervised\Schedular\Offering\Offering (normalizeTime, sessionWindows, effectiveEnrollmentDeadline, isEnrollmentOpen)
  • Admin controller: Unsupervised\Schedular\Offering\OfferingController
  • REST endpoint: Unsupervised\Schedular\Offering\OfferingEndpoint (public listing includes instructor_name)
  • Availability reconciliation: Unsupervised\Schedular\Offering\ClassSlotReconciler (uses Availability\AvailabilityRepository::findOverlapping)

Tests

  • tests/Unit/Offering/OfferingControllerTest.php
  • tests/Unit/Offering/OfferingRepositoryTest.php
  • tests/Unit/Offering/OfferingTest.php
  • tests/Unit/Offering/OfferingEndpointTest.php
  • tests/Unit/Offering/ClassSlotReconcilerTest.php