# 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`. - `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan. - `monthly` — **not** 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). `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 + (N−1) 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=`) 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`