CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A monthly group class multiplied its price by the sessions falling in the month, the same rule private lessons use — so a class priced at 40.00 CAD meeting weekly was billed 160.00 CAD on the 1st, and no studio could quote the price on a class card without lying about it. A group class is now billed its fee once for the month however many times it meets, which is what the card quotes and what the student ticks to agree to. Private lessons keep the per-lesson rule: their price is a per-lesson fee, and that is why the card quotes it per lesson. The session count still labels the month on the student's payment notice; it no longer prices it. Co-Authored-By: Claude Opus 5 <[email protected]>
135 lines
10 KiB
Markdown
135 lines
10 KiB
Markdown
# 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 that month. A **private lesson**'s price is a per-lesson fee, so the month is billed (#lessons in the month) × fee; a **group class**'s price is the monthly fee itself, billed once for the month however many times the class meets in it.
|
||
|
||
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 + (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=<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`
|