Compare commits
13
Commits
v1.1.0
...
d1dd30dc60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1dd30dc60 | ||
|
|
4328e8fb5f
|
||
|
|
36e7178158 | ||
|
|
32619a1b75
|
||
|
|
1a447743b3 | ||
|
|
fc7c0fa966
|
||
|
|
bf29162587
|
||
|
|
991ed2f5ad | ||
|
|
ad2ddefebf | ||
|
|
1fe28d5575 | ||
|
|
51dd032668
|
||
|
|
37ec8a3315 | ||
|
|
f93c5aba05 |
@@ -11,6 +11,19 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.1.3]
|
||||
|
||||
### Added
|
||||
- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date.
|
||||
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
|
||||
|
||||
## [1.1.2]
|
||||
|
||||
## [1.1.1]
|
||||
|
||||
### Fixed
|
||||
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,6 +46,26 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.us-my-lesson-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.us-my-lesson-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.us-my-lesson-duration {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.us-my-lesson-when {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.us-my-lesson-actions {
|
||||
@@ -54,6 +74,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-show-all-lessons {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-show-all-lessons:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.us-cancel-lesson {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
+38
-9
@@ -388,6 +388,25 @@
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// How many upcoming lessons to show before the "Show all" reveal.
|
||||
const INITIAL_LESSON_COUNT = 5;
|
||||
|
||||
function lessonRowHtml(l) {
|
||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
||||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||
return `
|
||||
<div class="us-my-lesson">
|
||||
<span class="us-my-lesson-info">
|
||||
<strong class="us-my-lesson-title">${title}${duration}</strong>
|
||||
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMyLessons(lessons) {
|
||||
const upcoming = lessons.filter((l) => l.start_dt);
|
||||
if (!upcoming.length) {
|
||||
@@ -395,20 +414,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Show only the soonest few by default; the rest sit hidden behind a
|
||||
// reveal so a busy student's list stays short.
|
||||
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
|
||||
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
|
||||
|
||||
myLessons.innerHTML = `
|
||||
<div class="us-my-lessons">
|
||||
<h3>Your upcoming lessons</h3>
|
||||
${upcoming.map((l) => `
|
||||
<div class="us-my-lesson">
|
||||
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${visible.map(lessonRowHtml).join('')}
|
||||
${hidden.length ? `
|
||||
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
|
||||
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
|
||||
` : ''}
|
||||
</div>`;
|
||||
|
||||
const moreBox = myLessons.querySelector('.us-my-lessons-more');
|
||||
const showAll = myLessons.querySelector('.us-show-all-lessons');
|
||||
if (showAll && moreBox) {
|
||||
showAll.addEventListener('click', () => {
|
||||
moreBox.hidden = false;
|
||||
showAll.remove();
|
||||
});
|
||||
}
|
||||
|
||||
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
|
||||
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
|
||||
});
|
||||
|
||||
@@ -103,6 +103,26 @@
|
||||
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
|
||||
// comparison against the class's Y-m-d enrolment deadline.
|
||||
function todayYmd() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The effective enrolment deadline: the instructor's set deadline, or the
|
||||
// first class day by default. Empty when the class has no dates at all.
|
||||
function enrolmentDeadline(o) {
|
||||
return o.enrollment_deadline || o.term_start || '';
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day. Mirrors the server-side
|
||||
// Offering::isEnrollmentOpen() gate.
|
||||
function isEnrollmentOpen(o) {
|
||||
const deadline = enrolmentDeadline(o);
|
||||
return !deadline || todayYmd() <= deadline;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledOfferingIds) {
|
||||
let groups = offerings.filter((o) => o.kind === 'group_class');
|
||||
if (singleOfferingId) {
|
||||
@@ -123,9 +143,14 @@
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${enrolledOfferingIds.has(Number(o.id))
|
||||
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
|
||||
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
|
||||
: (isEnrollmentOpen(o)
|
||||
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
|
||||
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ cancelled enrolment does not block re-enrolling).
|
||||
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||
a class at capacity rejects further enrolments.
|
||||
|
||||
Enrolment also closes after the class's **enrolment deadline** (the instructor's
|
||||
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
|
||||
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
|
||||
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
|
||||
of the Enrol button. While enrolment is still open the class card shows an
|
||||
"Enrol by" line with the effective deadline date.
|
||||
|
||||
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
|
||||
can still enrol someone by hand from the class **details page** — the **Add students
|
||||
directly** control, available for every group class, deliberately bypasses the
|
||||
deadline (and capacity) so a **late enrolment** can be added after the class has
|
||||
closed. Past the deadline the details page labels these as late enrolments. See
|
||||
**Admin Interface** below.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|----------------------------------------------|----------------------------------|
|
||||
@@ -74,13 +88,15 @@ flips their grant from `invited` to `enrolled`.
|
||||
|
||||
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
|
||||
person, separate from the enrolment itself. The instructor manages access from
|
||||
**My Lessons → My Group Classes**, which renders three controls under each invite-only
|
||||
class:
|
||||
**My Lessons → My Group Classes**. **Add students directly** is available on every
|
||||
class's details page (see **Admin Interface**); invite-only classes add two more
|
||||
controls beneath it:
|
||||
|
||||
1. **Add students directly** — the selected registered students are enrolled immediately
|
||||
(`status = active`) with a **pending payment** at the class price (comp students are
|
||||
settled at once by `PaymentService`). No access grant is needed — this writes straight
|
||||
to `us_group_enrollments` + `us_payments`.
|
||||
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
|
||||
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
||||
2. **Make available** — the selected registered students get an `invited` grant so the
|
||||
class appears in their own group-class list; they then self-enrol through the normal
|
||||
paid flow. Each is emailed a "you've been added" notice.
|
||||
@@ -121,12 +137,14 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
against capacity, plus a **View details** link (**View & invite** for invite-only
|
||||
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
|
||||
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
|
||||
price, schedule note, description, status), the roster of enrolled students with enrolment
|
||||
and payment status, and — for invite-only classes — an **Invite & enrol students** section
|
||||
listing who has been invited but not yet enrolled alongside the add/make-available/
|
||||
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
|
||||
instructor). Managing who is in an invite-only class is therefore done entirely from this
|
||||
page. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
price, schedule note, enrolment deadline, status), the roster of enrolled students with
|
||||
enrolment and payment status, and an **Add students** section. Every class — public or
|
||||
invite-only — carries the **Add students directly** control there, which enrols the
|
||||
selected students immediately (a late enrolment past the deadline; the section says so
|
||||
when the deadline has passed). Invite-only classes additionally get the
|
||||
**make-available** and **invite-by-email** controls plus the list of who has been invited
|
||||
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
|
||||
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
(`templates/admin/my-group-class-detail.php`) are separate templates.
|
||||
|
||||
## Implementation
|
||||
|
||||
@@ -29,7 +29,7 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
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.
|
||||
10. 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.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
@@ -85,7 +85,12 @@ kind `group_class`; see `group-classes.md`.
|
||||
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.
|
||||
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
|
||||
@@ -96,6 +101,7 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
- 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`
|
||||
|
||||
@@ -109,3 +115,6 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
## 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`
|
||||
|
||||
@@ -15,12 +15,13 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
| `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` (single booking) or `full_term` (weekly / group) |
|
||||
| `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) |
|
||||
| `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`) |
|
||||
@@ -30,6 +31,12 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
## 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
|
||||
@@ -51,6 +58,16 @@ one-off), and returns an empty list unless date, time, and a positive duration a
|
||||
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`.
|
||||
|
||||
## Instructor assignment
|
||||
Every offering has an owning `instructor_id`. A studio admin
|
||||
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
|
||||
@@ -84,7 +101,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
|
||||
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
|
||||
- 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`)
|
||||
|
||||
@@ -88,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
|
||||
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
|
||||
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
|
||||
| `due_date` | DATE | When a *scheduled* payment is due; NULL = due at registration (`Payment::isScheduled()`) |
|
||||
| `period_key` | VARCHAR(20) | Scheduled-billing dedup key: session date (weekly) or `YYYY-MM` (monthly); NULL otherwise |
|
||||
| `notice_batch` | VARCHAR(32) | Shared reference for the payments one due-notice email covers, so a lump-sum e-transfer reconciles to them; NULL otherwise |
|
||||
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
|
||||
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
|
||||
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
|
||||
@@ -102,6 +105,19 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`.
|
||||
5. For an e-transfer, the studio admin later calls `PATCH /payments/{id}` to mark it `paid`, which triggers the same confirmation + receipt.
|
||||
|
||||
## Scheduled Billing (weekly / monthly)
|
||||
`weekly` and `monthly` offerings are **not** charged at registration. The booking /
|
||||
enrolment succeeds with `payment: null`; the lesson is confirmed (or the enrolment stays
|
||||
active) immediately, and payments are generated later by the daily
|
||||
`us_generate_due_payments` cron scan (`Payment\ScheduledBillingRunner`). Each generated
|
||||
payment carries a `due_date` and `period_key`, flows through the same
|
||||
`PaymentService::createForRegistration` (so HST, method resolution, e-transfer freezing
|
||||
and comp auto-pay are identical), and the student is emailed one consolidated itemised
|
||||
notice per scan (`Payment\PaymentDueMailer`). Because these payments are scheduled,
|
||||
`PaymentService::voidPending` never voids them — cancelling one lesson leaves a shared
|
||||
monthly charge (and every other lesson it covers) untouched, and never rebills. Full
|
||||
model, dedup, and the four generation cases are documented in `scheduled-billing.md`.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|---------|---------------------------------------------|-----------------------------|
|
||||
|
||||
@@ -50,9 +50,16 @@ update for a same-slug plugin and makes core fire the
|
||||
4. When newer, returns the release's first `.zip` asset as the update
|
||||
package. Core takes over from there: Plugins-screen notice, one-click
|
||||
update, and WP-Cron auto-updates if enabled.
|
||||
5. When not newer — the site is current, or the lookup failed — returns a
|
||||
`no_update` payload (installed version, empty package). This keeps the
|
||||
plugin in core's `update_plugins` transient so core's `update-supported`
|
||||
flag stays set and the **Enable auto-updates** toggle shows on the
|
||||
Plugins screen. Without it, an off-directory plugin is absent from the
|
||||
transient between releases and the toggle never appears.
|
||||
|
||||
Any API failure, malformed response, or asset-less release degrades to
|
||||
"no update available" — never an error surfaced to the site.
|
||||
"no update available" (the `no_update` payload) — never an error surfaced
|
||||
to the site, and never a lost auto-update toggle during a Gitea blip.
|
||||
|
||||
## Cutting a Release
|
||||
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Feature: Scheduled Billing (weekly / monthly)
|
||||
|
||||
## Overview
|
||||
Two offering billing modes defer payment past registration and generate pending
|
||||
payments on a recurring schedule:
|
||||
|
||||
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
||||
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
||||
lesson that falls in the month (4 lessons ⇒ 4 × fee).
|
||||
|
||||
Both apply to **private lessons** and **group classes**. At registration the
|
||||
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
||||
confirmed / the enrolment stays active immediately. Payments are created later by a daily
|
||||
WP-Cron scan, and the student is emailed one consolidated notice per scan. Collection
|
||||
uses the existing rails (e-transfer confirmed by the studio admin, or card) — there is no
|
||||
automatic card charging.
|
||||
|
||||
## The daily scan — `Payment\ScheduledBillingRunner`
|
||||
Hooked to the WP-Cron action **`us_generate_due_payments`** (scheduled `daily` by
|
||||
`Installer`, cleared on plugin deactivation). `run()` is self-healing: it re-derives
|
||||
everything due from current ledger state each run, so a missed day is simply picked up
|
||||
next time. Every payment is created through `PaymentService::createForRegistration` (HST,
|
||||
method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date` and
|
||||
`period_key` set.
|
||||
|
||||
### The four generation cases
|
||||
| Source | When it bills | Amount | Dedup |
|
||||
|--------|---------------|--------|-------|
|
||||
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
||||
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
||||
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
||||
| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` |
|
||||
|
||||
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
||||
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
||||
runner points the remaining lessons at the same payment.
|
||||
- Group enrolments (one row per whole term) dedup on `period_key` via
|
||||
`PaymentRepository::existsForPeriod()`, since one enrolment maps to many periodic
|
||||
charges.
|
||||
- Only offerings with a positive price are billed; cancelled lessons are excluded, so a
|
||||
lesson cancelled before its payment is generated is simply never billed.
|
||||
|
||||
### Late bookings charge at booking time
|
||||
A single scheduled lesson booked **after** its due date has already passed is charged at
|
||||
booking instead of deferred (`BookingEndpoint::scheduledDueHasPassed`): an extra monthly
|
||||
lesson added to a month that was already billed (its 1st has arrived), or a weekly lesson
|
||||
booked within 24 hours of the session. These create a normal at-registration payment (no
|
||||
`due_date`), so the fee is collected once, at booking, and never billed late by the scan.
|
||||
This applies only to single bookings — a weekly reservation series always defers, each
|
||||
lesson billed by the scan on its own schedule.
|
||||
|
||||
## Notification — `Payment\PaymentDueMailer`
|
||||
As the runner creates each **pending** payment it appends an itemised line to that
|
||||
student's notice bucket; after all cases run it sends **one** email per student with a
|
||||
line per item (label · due date · amount) and a grand total, plus the e-transfer
|
||||
destination(s). A student billed for several lessons on one day is emailed once, never
|
||||
per lesson. Comp payments (auto-paid) are not bucketed.
|
||||
|
||||
### Notice batch (lump-sum reconciliation)
|
||||
All the payments in one student's notice are tagged with a shared **notice batch**
|
||||
reference (`us_payments.notice_batch`, `PaymentRepository::assignNoticeBatch`), which is
|
||||
printed on the email so the student can quote it. In the **Payments** admin queue those
|
||||
payments are shown grouped under that reference with a combined lump-sum total
|
||||
(`PaymentController::groupPending`), so when one e-transfer arrives for the whole notice
|
||||
the admin can see exactly which pending payments — and therefore which bookings — it
|
||||
covers. Each is still confirmed individually with **Mark received**. Legacy
|
||||
at-registration payments have no batch and appear on their own.
|
||||
|
||||
## Cancellation
|
||||
Scheduled payments are never auto-voided. `PaymentService::voidPending` acts only on
|
||||
legacy at-registration payments (`! Payment::isScheduled()`), so cancelling one lesson
|
||||
never voids a shared monthly charge, never refunds, and never rebills. Refunds/credits
|
||||
are a manual, admin-side decision.
|
||||
|
||||
## Implementation
|
||||
- Runner: `Unsupervised\Schedular\Payment\ScheduledBillingRunner`
|
||||
- Notice email: `Unsupervised\Schedular\Payment\PaymentDueMailer`
|
||||
- Finders: `Booking\BookingRepository::findUnbilledScheduledLessons`,
|
||||
`GroupClass\EnrollmentRepository::findActiveByBillingModes`
|
||||
- Dedup: `Payment\PaymentRepository::existsForPeriod`
|
||||
- Session windows: `Offering\Offering::sessionWindows`
|
||||
- Cron scheduling: `Installer::scheduleBilling`; cleared in `unsupervised-schedular.php`
|
||||
deactivation hook.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Payment/ScheduledBillingRunnerTest.php`
|
||||
- `tests/Unit/Payment/PaymentDueMailerTest.php`
|
||||
- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
|
||||
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
|
||||
+2
-1
@@ -17,6 +17,7 @@ use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
@@ -57,7 +58,7 @@ class AdminMenu {
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
|
||||
@@ -123,17 +123,27 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson's array form plus its slot's start/end times, so front-end lists
|
||||
* can show when the session happens without a second request.
|
||||
* A lesson's array form plus its slot's start/end times and the booked
|
||||
* offering's name, so front-end lists can show what the session is and when
|
||||
* it happens without a second request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonWithTimes( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
// Prefer the offering's own length; fall back to the slot's when the
|
||||
// offering has none (a generic, duration-less type).
|
||||
$duration = null !== $offering && null !== $offering->durationMinutes
|
||||
? $offering->durationMinutes
|
||||
: $slot?->durationMinutes;
|
||||
|
||||
return $lesson->toArray() + [
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'offering_title' => $offering?->title,
|
||||
'duration_minutes' => $duration,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -249,7 +259,16 @@ class BookingEndpoint {
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
// charged at booking instead, so it is never missed or billed late.
|
||||
$chargeAtBooking = $offering->price > 0.0 && (
|
||||
! $offering->isScheduledBilling()
|
||||
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
|
||||
);
|
||||
|
||||
if ( $chargeAtBooking ) {
|
||||
// A full-term price already covers the whole reservation; a per-lesson
|
||||
// (one_time) price is owed once per occurrence actually claimed, so a
|
||||
// weekly reservation cannot hold a term while paying for one week.
|
||||
@@ -263,8 +282,10 @@ class BookingEndpoint {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Free offering: there is no payment step that would confirm these
|
||||
// lessons later, so they are confirmed at booking time.
|
||||
// Either a free offering, or scheduled billing (weekly / monthly) whose
|
||||
// payment is deferred to the daily billing scan. Either way there is no
|
||||
// payment step now to confirm the lessons, so the reserved slots are
|
||||
// confirmed at booking time; the billing scan bills them when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
@@ -296,6 +317,23 @@ class BookingEndpoint {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a given session has
|
||||
* already passed at booking time. Weekly bills 24 hours before the lesson;
|
||||
* monthly bills on the 1st, so its due moment has passed once "now" is in the
|
||||
* lesson's month or later. Only meaningful for weekly / monthly offerings.
|
||||
*/
|
||||
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
|
||||
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
|
||||
$start = new \DateTimeImmutable( $slotStart );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
|
||||
}
|
||||
|
||||
return $now >= $start->modify( '-1 day' );
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
@@ -204,6 +204,45 @@ class BookingRepository {
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering:
|
||||
* status not cancelled and no payment attached yet. Each row carries the slot
|
||||
* start time and the offering's billing fields so the daily billing scan can
|
||||
* decide what is due without a second query per lesson. Ordered by student,
|
||||
* offering and time so the scan can group a student's monthly lessons cheaply.
|
||||
*
|
||||
* @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id,
|
||||
* start_dt, billing_mode, title, price, currency,
|
||||
* etransfer_email.
|
||||
*/
|
||||
public function findUnbilledScheduledLessons(): array {
|
||||
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||
$offTable = str_replace( 'us_lessons', 'us_offerings', $this->table );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT l.id, l.student_id, l.instructor_id, l.offering_id,
|
||||
a.start_dt,
|
||||
o.billing_mode, o.title, o.price, o.currency, o.etransfer_email
|
||||
FROM %i l
|
||||
JOIN %i a ON a.id = l.slot_id
|
||||
JOIN %i o ON o.id = l.offering_id
|
||||
WHERE l.status != %s
|
||||
AND l.payment_id IS NULL
|
||||
AND o.billing_mode IN ( %s, %s )
|
||||
ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC',
|
||||
$this->table,
|
||||
$avTable,
|
||||
$offTable,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY
|
||||
)
|
||||
);
|
||||
|
||||
return $rows ?? [];
|
||||
}
|
||||
|
||||
public function setPaymentId( int $id, int $paymentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WeekCalendar;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
@@ -17,6 +18,8 @@ class LessonController {
|
||||
private BookingRepository $repository,
|
||||
private PaymentRepository $payments,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private LessonDetail $detail,
|
||||
) {}
|
||||
|
||||
public function renderAdminDashboard(): void {
|
||||
@@ -24,6 +27,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( false );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
|
||||
@@ -36,6 +43,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( true );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
|
||||
@@ -43,6 +54,36 @@ class LessonController {
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the request targets a single lesson (`?lesson_id=`), render its detail
|
||||
* view and report that the page has been handled. Instructors may only open
|
||||
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
|
||||
*/
|
||||
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
|
||||
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
|
||||
if ( $lessonId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lesson = $this->repository->findById( $lessonId );
|
||||
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
||||
|
||||
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
|
||||
$row = null;
|
||||
$answers = [];
|
||||
$accepts = [];
|
||||
} else {
|
||||
$row = $this->row( $lesson );
|
||||
$answers = $this->detail->answers( $lessonId );
|
||||
$accepts = $this->detail->acceptances( $lessonId );
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the lessons template with its calendar view state: week (default)
|
||||
* or list, plus which week the week view shows.
|
||||
@@ -111,10 +152,15 @@ class LessonController {
|
||||
$instructor = get_userdata( $lesson->instructorId );
|
||||
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
return [
|
||||
'lesson_id' => (int) $lesson->id,
|
||||
'student' => $student ? $student->display_name : (string) $lesson->studentId,
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
|
||||
'offering' => $offering ? $offering->title : '—',
|
||||
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
|
||||
'recurrence' => $lesson->recurrence,
|
||||
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
|
||||
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
|
||||
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for the admin lesson detail view: the intake answers
|
||||
* the student submitted and the policy versions they accepted when booking.
|
||||
*
|
||||
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
||||
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*/
|
||||
class LessonDetail {
|
||||
|
||||
public function __construct(
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The intake-question answers recorded for this lesson, in submission order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string}>
|
||||
*/
|
||||
public function answers( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
$value = $answer->answerValue ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted when booking this lesson, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
||||
*/
|
||||
public function acceptances( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( PolicyAcceptance $acceptance ): array {
|
||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
return [
|
||||
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
|
||||
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
'accepted_at' => $acceptance->acceptedAt ?? '',
|
||||
'ip' => $acceptance->ipAddress ?? '',
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,12 @@ class EnrollmentEndpoint {
|
||||
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day — the instructor's set
|
||||
// deadline, or the first class day by default.
|
||||
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
|
||||
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
|
||||
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
@@ -123,8 +129,11 @@ class EnrollmentEndpoint {
|
||||
$this->access->markEnrolled( $offeringId, $studentId );
|
||||
}
|
||||
|
||||
// Scheduled billing (weekly / monthly) is generated later by the daily
|
||||
// billing scan, so nothing is charged at enrolment; the enrolment is active
|
||||
// regardless of payment.
|
||||
$payment = null;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,39 @@ class EnrollmentRepository {
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Active enrolments whose group class bills on a scheduled mode (weekly /
|
||||
* monthly) — the source rows for the daily billing scan. Filtered by joining
|
||||
* the offering so only classes actually on a scheduled plan are returned.
|
||||
*
|
||||
* @param list<string> $modes Billing modes to include (e.g. weekly, monthly).
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findActiveByBillingModes( array $modes ): array {
|
||||
if ( [] === $modes ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table );
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT e.* FROM %i e
|
||||
JOIN %i o ON o.id = e.offering_id
|
||||
WHERE e.status = %s
|
||||
AND o.billing_mode IN ( {$placeholders} )
|
||||
ORDER BY e.student_id ASC, e.offering_id ASC",
|
||||
$this->table,
|
||||
$offTable,
|
||||
Enrollment::STATUS_ACTIVE,
|
||||
...$modes
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function setPaymentId( int $id, int $paymentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -176,7 +176,7 @@ class GroupClassController {
|
||||
* invite-only classes — the list of people invited but not yet enrolled.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
*/
|
||||
private function classDetail( Offering $offering, array $enrollments ): array {
|
||||
$roster = [];
|
||||
@@ -195,16 +195,20 @@ class GroupClassController {
|
||||
];
|
||||
}
|
||||
|
||||
$deadline = $offering->effectiveEnrollmentDeadline();
|
||||
|
||||
return $this->classSummary( $offering, $enrollments ) + [
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'duration' => $offering->durationMinutes,
|
||||
'description' => $offering->description,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'duration' => $offering->durationMinutes,
|
||||
'description' => $offering->description,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
|
||||
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -312,6 +316,10 @@ class GroupClassController {
|
||||
* Directly enrol registered students, each with a pending payment at the
|
||||
* class price (comp students are settled immediately by the payment service).
|
||||
*
|
||||
* This is the instructor's manual enrolment path and deliberately bypasses the
|
||||
* enrolment deadline and capacity, so a student can be added as a late
|
||||
* enrolment after the class has closed to self-enrolment.
|
||||
*
|
||||
* @param list<int> $studentIds
|
||||
*/
|
||||
private function addDirect( Offering $offering, array $studentIds ): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
|
||||
class Installer {
|
||||
|
||||
@@ -12,10 +13,22 @@ class Installer {
|
||||
$this->createTables();
|
||||
$this->migrateData();
|
||||
( new RoleManager() )->createRoles();
|
||||
$this->scheduleBilling();
|
||||
flush_rewrite_rules();
|
||||
update_option( 'us_schedular_version', USC_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the daily scheduled-billing scan is registered with WP-Cron. Runs on
|
||||
* activation and on every version-bump re-install, so an existing site that
|
||||
* predates the feature picks the event up on its next deploy.
|
||||
*/
|
||||
private function scheduleBilling(): void {
|
||||
if ( false === wp_next_scheduled( ScheduledBillingRunner::HOOK ) ) {
|
||||
wp_schedule_event( time(), 'daily', ScheduledBillingRunner::HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
private function createTables(): void {
|
||||
global $wpdb;
|
||||
if ( ! $wpdb instanceof \wpdb ) {
|
||||
|
||||
@@ -20,12 +20,26 @@ class Offering {
|
||||
public const BILLING_ONE_TIME = 'one_time';
|
||||
public const BILLING_FULL_TERM = 'full_term';
|
||||
|
||||
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
|
||||
public const BILLING_WEEKLY = 'weekly';
|
||||
|
||||
/** Billed on the first of each month for every lesson that falls in the month. */
|
||||
public const BILLING_MONTHLY = 'monthly';
|
||||
|
||||
/**
|
||||
* All valid billing modes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/**
|
||||
* Billing modes whose payment is generated later by the daily billing scan
|
||||
* rather than taken at registration.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
|
||||
public const ACCESS_PUBLIC = 'public';
|
||||
@@ -54,6 +68,7 @@ class Offering {
|
||||
public readonly ?string $termStart = null,
|
||||
public readonly ?string $termEnd = null,
|
||||
public readonly ?string $classTime = null,
|
||||
public readonly ?string $enrollmentDeadline = null,
|
||||
public readonly ?string $scheduleNote = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?int $cancellationCutoffHours = null,
|
||||
@@ -70,6 +85,35 @@ class Offering {
|
||||
return self::ACCESS_INVITE_ONLY === $this->accessMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this offering's payment is deferred to the daily billing scan
|
||||
* (weekly / monthly) instead of being taken at registration.
|
||||
*/
|
||||
public function isScheduledBilling(): bool {
|
||||
return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* The last day on which a student may enrol in this group class. Defaults to
|
||||
* the first day of the class (`term_start`) when the instructor has not set an
|
||||
* explicit deadline; null only when the class has no dates at all.
|
||||
*/
|
||||
public function effectiveEnrollmentDeadline(): ?string {
|
||||
return $this->enrollmentDeadline ?? $this->termStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays
|
||||
* open through the end of the deadline day, so the first class is still
|
||||
* enrollable under the default deadline. A class with no deadline at all (no
|
||||
* dates configured) is always open.
|
||||
*/
|
||||
public function isEnrollmentOpen( string $today ): bool {
|
||||
$deadline = $this->effectiveEnrollmentDeadline();
|
||||
|
||||
return null === $deadline || $today <= $deadline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
|
||||
* not a real calendar date. Round-trips through DateTimeImmutable so
|
||||
@@ -169,6 +213,7 @@ class Offering {
|
||||
termStart: Val::stringOrNull( $row->term_start ),
|
||||
termEnd: Val::stringOrNull( $row->term_end ),
|
||||
classTime: Val::stringOrNull( $row->class_time ?? null ),
|
||||
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
|
||||
scheduleNote: Val::stringOrNull( $row->schedule_note ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
|
||||
@@ -203,6 +248,7 @@ class Offering {
|
||||
'term_start' => $this->termStart,
|
||||
'term_end' => $this->termEnd,
|
||||
'class_time' => $this->classTime,
|
||||
'enrollment_deadline' => $this->enrollmentDeadline,
|
||||
'schedule_note' => $this->scheduleNote,
|
||||
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
|
||||
'access_mode' => $this->accessMode,
|
||||
|
||||
@@ -209,6 +209,10 @@ class OfferingController {
|
||||
|
||||
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
|
||||
|
||||
// A blank (or invalid) deadline means "use the default" — the first class
|
||||
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
|
||||
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
|
||||
|
||||
return new Offering(
|
||||
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
|
||||
kind: $kind,
|
||||
@@ -223,6 +227,7 @@ class OfferingController {
|
||||
termStart: $termStart,
|
||||
termEnd: $termEnd,
|
||||
classTime: $classTime,
|
||||
enrollmentDeadline: $enrollmentDeadline,
|
||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
||||
cancellationCutoffHours: $cutoffHours,
|
||||
|
||||
@@ -161,6 +161,7 @@ class OfferingEndpoint {
|
||||
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
|
||||
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
||||
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
||||
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
||||
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
||||
@@ -208,6 +209,7 @@ class OfferingEndpoint {
|
||||
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity,
|
||||
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
||||
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
||||
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
||||
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
|
||||
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
|
||||
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
||||
|
||||
@@ -14,12 +14,13 @@ class OfferingRepository {
|
||||
/**
|
||||
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
|
||||
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
|
||||
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
|
||||
* cancellation_cutoff_hours, access_mode, is_active).
|
||||
* capacity, term_start, term_end, class_time, enrollment_deadline,
|
||||
* schedule_note, etransfer_email, cancellation_cutoff_hours, access_mode,
|
||||
* is_active).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
|
||||
|
||||
public function insert( Offering $offering ): int {
|
||||
$this->db->insert(
|
||||
@@ -61,6 +62,7 @@ class OfferingRepository {
|
||||
'term_start' => $offering->termStart,
|
||||
'term_end' => $offering->termEnd,
|
||||
'class_time' => $offering->classTime,
|
||||
'enrollment_deadline' => $offering->enrollmentDeadline,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'etransfer_email' => $offering->etransferEmail,
|
||||
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
|
||||
|
||||
@@ -44,6 +44,9 @@ class Payment {
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
public readonly float $taxRate = 0.0,
|
||||
public readonly float $taxAmount = 0.0,
|
||||
public readonly ?string $dueDate = null,
|
||||
public readonly ?string $periodKey = null,
|
||||
public readonly ?string $noticeBatch = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?string $stripePaymentIntentId = null,
|
||||
public readonly ?string $receiptNumber = null,
|
||||
@@ -65,6 +68,9 @@ class Payment {
|
||||
status: Val::string( $row->status ),
|
||||
taxRate: Val::float( $row->tax_rate ),
|
||||
taxAmount: Val::float( $row->tax_amount ),
|
||||
dueDate: Val::stringOrNull( $row->due_date ?? null ),
|
||||
periodKey: Val::stringOrNull( $row->period_key ?? null ),
|
||||
noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
|
||||
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
||||
@@ -79,6 +85,15 @@ class Payment {
|
||||
return self::STATUS_PAID === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this payment was generated by the daily billing scan (weekly /
|
||||
* monthly) rather than taken at registration. Scheduled payments carry a due
|
||||
* date, can cover several lessons, and are never auto-voided on cancellation.
|
||||
*/
|
||||
public function isScheduled(): bool {
|
||||
return null !== $this->dueDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount billed including tax.
|
||||
*/
|
||||
@@ -120,6 +135,9 @@ class Payment {
|
||||
'currency' => $this->currency,
|
||||
'method' => $this->method,
|
||||
'status' => $this->status,
|
||||
'due_date' => $this->dueDate,
|
||||
'period_key' => $this->periodKey,
|
||||
'notice_batch' => $this->noticeBatch,
|
||||
'receipt_number' => $this->receiptNumber,
|
||||
'paid_at' => $this->paidAt,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -33,22 +33,59 @@ class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
static function ( Payment $payment ): array {
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
return [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
},
|
||||
$this->payments->findPending()
|
||||
);
|
||||
$groups = $this->groupPending( $this->payments->findPending() );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group pending payments by their shared notice batch, so payments the daily
|
||||
* scan emailed a student together (and which a single lump-sum e-transfer
|
||||
* covers) are shown as one group with a combined total. Payments with no batch
|
||||
* — legacy at-registration e-transfers — are each their own single-item group.
|
||||
*
|
||||
* @param list<Payment> $pending
|
||||
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
|
||||
*/
|
||||
private function groupPending( array $pending ): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ( $pending as $payment ) {
|
||||
$batch = (string) $payment->noticeBatch;
|
||||
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
|
||||
|
||||
if ( ! isset( $groups[ $key ] ) ) {
|
||||
$groups[ $key ] = [
|
||||
'reference' => $batch,
|
||||
'currency' => $payment->currency,
|
||||
'total_raw' => 0.0,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
$groups[ $key ]['total_raw'] += $payment->total();
|
||||
$groups[ $key ]['rows'][] = [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( array $group ): array => [
|
||||
'reference' => $group['reference'],
|
||||
'is_group' => count( $group['rows'] ) > 1,
|
||||
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
|
||||
'rows' => $group['rows'],
|
||||
],
|
||||
$groups
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
/**
|
||||
* Emails a student a single itemised notice for every payment the daily billing
|
||||
* scan generated for them in one run, so a student billed for several lessons on
|
||||
* the same day receives one email with a line per item and a grand total — never
|
||||
* one email per lesson.
|
||||
*/
|
||||
class PaymentDueMailer {
|
||||
|
||||
/**
|
||||
* Send one student their consolidated due-payment notice for the current scan.
|
||||
* The optional `$reference` is the shared notice-batch code the student can quote
|
||||
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
|
||||
*
|
||||
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
|
||||
* @return bool False when there is no recipient or nothing to bill.
|
||||
*/
|
||||
public function send( \WP_User $student, array $items, string $reference = '' ): bool {
|
||||
if ( '' === (string) $student->user_email || [] === $items ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currency = (string) $items[0]['currency'];
|
||||
$total = 0.0;
|
||||
$lines = [];
|
||||
$emails = [];
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
$amount = (float) $item['amount'];
|
||||
$total += $amount;
|
||||
|
||||
$lines[] = sprintf(
|
||||
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */
|
||||
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ),
|
||||
(string) $item['label'],
|
||||
$this->formatDate( $item['due_date'] ?? null ),
|
||||
$currency,
|
||||
number_format( $amount, 2 )
|
||||
);
|
||||
|
||||
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
|
||||
if ( '' !== $etransfer ) {
|
||||
$emails[ $etransfer ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
|
||||
. implode( "\n", $lines ) . "\n\n"
|
||||
. sprintf(
|
||||
/* translators: 1: currency, 2: total amount */
|
||||
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
number_format( $total, 2 )
|
||||
);
|
||||
|
||||
if ( [] !== $emails ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: %s: e-transfer destination email address(es) */
|
||||
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
|
||||
implode( ', ', array_keys( $emails ) )
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' !== $reference ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: %s: payment reference code */
|
||||
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
|
||||
$reference
|
||||
);
|
||||
}
|
||||
|
||||
return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a stored `Y-m-d` due date in a friendlier form; falls back to the
|
||||
* raw value (or an empty string) when it is not a parseable date.
|
||||
*/
|
||||
private function formatDate( ?string $date ): string {
|
||||
if ( null === $date || '' === $date ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
|
||||
|
||||
return false !== $parsed ? $parsed->format( 'M j, Y' ) : $date;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ class PaymentRepository {
|
||||
'status' => $payment->status,
|
||||
'tax_rate' => $payment->taxRate,
|
||||
'tax_amount' => $payment->taxAmount,
|
||||
'due_date' => $payment->dueDate,
|
||||
'period_key' => $payment->periodKey,
|
||||
'notice_batch' => $payment->noticeBatch,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
@@ -32,7 +35,7 @@ class PaymentRepository {
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -119,6 +122,51 @@ class PaymentRepository {
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a set of payments with a shared notice-batch reference — the payments the
|
||||
* daily scan emailed a student together, so the admin can see which pending
|
||||
* payments a single lump-sum e-transfer covers. No-op for an empty id list.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function assignNoticeBatch( array $ids, string $batch ): void {
|
||||
if ( [] === $ids ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$sql = $this->db->prepare(
|
||||
"UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )",
|
||||
$this->table,
|
||||
$batch,
|
||||
...$ids
|
||||
);
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled payment already exists for a registration and billing
|
||||
* period. The daily billing scan uses this to avoid double-billing an
|
||||
* enrolment for the same session (weekly) or month (monthly). A voided
|
||||
* (`failed`) row still counts so a cancelled charge is not silently re-created.
|
||||
*/
|
||||
public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1',
|
||||
$this->table,
|
||||
$registrationType,
|
||||
$registrationId,
|
||||
$periodKey
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
|
||||
@@ -29,8 +29,12 @@ class PaymentService {
|
||||
* (card via Stripe — coming soon; e-transfer confirmed manually). The
|
||||
* e-transfer destination is frozen now from the offering override or the studio
|
||||
* default. Returns null when the registration has no price to charge.
|
||||
*
|
||||
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
||||
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
||||
* the pay-now flow.
|
||||
*/
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment {
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
|
||||
if ( $amount <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +62,8 @@ class PaymentService {
|
||||
status: $status,
|
||||
taxRate: $taxRate,
|
||||
taxAmount: $taxAmount,
|
||||
dueDate: $dueDate,
|
||||
periodKey: $periodKey,
|
||||
etransferEmail: $etransferEmail,
|
||||
)
|
||||
);
|
||||
@@ -71,6 +77,26 @@ class PaymentService {
|
||||
return $this->payments->findById( $id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled payment already exists for a registration and billing
|
||||
* period — the daily billing scan's dedup check for group enrolments (whose one
|
||||
* row maps to many periodic charges). Delegates to the ledger.
|
||||
*/
|
||||
public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool {
|
||||
return $this->payments->existsForPeriod( $type, $registrationId, $periodKey );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the payments the daily scan emailed a student together with a shared
|
||||
* notice-batch reference, so a lump-sum e-transfer can be reconciled to the
|
||||
* pending payments it covers. Delegates to the ledger.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function assignNoticeBatch( array $ids, string $batch ): void {
|
||||
$this->payments->assignNoticeBatch( $ids, $batch );
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||
* Marks it paid, confirms the registration, and emails the receipt.
|
||||
@@ -92,7 +118,10 @@ class PaymentService {
|
||||
/**
|
||||
* Void the still-pending payment of a cancelled registration so it drops
|
||||
* out of the confirmation queue. Paid payments are left alone — refunds
|
||||
* are a manual, admin-side decision.
|
||||
* are a manual, admin-side decision. Scheduled payments (weekly / monthly)
|
||||
* are also left alone: a monthly charge can cover several lessons and may
|
||||
* already be collected, so cancelling one lesson must never void it or
|
||||
* trigger a rebill.
|
||||
*/
|
||||
public function voidPending( ?int $paymentId ): void {
|
||||
if ( null === $paymentId ) {
|
||||
@@ -100,7 +129,7 @@ class PaymentService {
|
||||
}
|
||||
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
|
||||
if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Generates the pending payments that scheduled-billing offerings (weekly /
|
||||
* monthly) owe as they come due, then emails each student one itemised notice.
|
||||
*
|
||||
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
|
||||
* self-healing: every run re-scans from the current ledger state, so a missed
|
||||
* day is simply picked up the next time. Dedup keeps a second run from
|
||||
* double-billing — private lessons via `us_lessons.payment_id`, group enrolments
|
||||
* via `us_payments.period_key`.
|
||||
*/
|
||||
class ScheduledBillingRunner {
|
||||
|
||||
public const HOOK = 'us_generate_due_payments';
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $payments,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentDueMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
add_action( self::HOOK, [ $this, 'run' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate every payment now due and send the consolidated notices.
|
||||
*/
|
||||
public function run(): void {
|
||||
$now = $this->now();
|
||||
|
||||
// One notice bucket per student, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a student billed for several
|
||||
// lessons on one day is emailed once — never once per lesson. $batchIds
|
||||
// tracks the payment ids behind each student's bucket so they can be tagged
|
||||
// with a shared reference for lump-sum e-transfer reconciliation.
|
||||
$buckets = [];
|
||||
$batchIds = [];
|
||||
|
||||
$this->billPrivateLessons( $now, $buckets, $batchIds );
|
||||
$this->billGroupEnrollments( $now, $buckets, $batchIds );
|
||||
|
||||
$this->sendNotices( $buckets, $batchIds );
|
||||
}
|
||||
|
||||
/**
|
||||
* Private-lesson billing. Weekly lessons are billed one payment each once they
|
||||
* are within 24 hours; monthly lessons are grouped per calendar month and billed
|
||||
* one payment for the month once its 1st has arrived.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$monthly = [];
|
||||
|
||||
foreach ( $this->bookings->findUnbilledScheduledLessons() as $row ) {
|
||||
$price = Val::float( $row->price ?? 0 );
|
||||
if ( $price <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startRaw = Val::string( $row->start_dt ?? '' );
|
||||
$start = false !== strtotime( $startRaw ) ? new \DateTimeImmutable( $startRaw ) : null;
|
||||
if ( null === $start ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lessonId = Val::int( $row->id );
|
||||
$studentId = Val::int( $row->student_id );
|
||||
$instructorId = Val::int( $row->instructor_id );
|
||||
$currency = Val::string( $row->currency ?? 'CAD' );
|
||||
$etransfer = Val::stringOrNull( $row->etransfer_email ?? null );
|
||||
$title = Val::string( $row->title ?? '' );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === Val::string( $row->billing_mode ?? '' ) ) {
|
||||
$monthly[ $studentId . ':' . Val::int( $row->offering_id ) . ':' . $start->format( 'Y-m' ) ][] = [
|
||||
'lesson_id' => $lessonId,
|
||||
'student_id' => $studentId,
|
||||
'instructor_id' => $instructorId,
|
||||
'currency' => $currency,
|
||||
'etransfer' => $etransfer,
|
||||
'title' => $title,
|
||||
'price' => $price,
|
||||
'start' => $start,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Weekly: due 24 hours before the lesson.
|
||||
$due = $start->modify( '-1 day' );
|
||||
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$lessonId,
|
||||
$studentId,
|
||||
$instructorId,
|
||||
$price,
|
||||
$currency,
|
||||
$etransfer,
|
||||
$due->format( 'Y-m-d' ),
|
||||
$start->format( 'Y-m-d' ),
|
||||
$title . ' — ' . $start->format( 'M j, Y' )
|
||||
);
|
||||
}
|
||||
|
||||
$this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds );
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill each month's worth of monthly private lessons as one payment (count ×
|
||||
* fee), once the month's 1st has arrived. The payment links to the earliest
|
||||
* lesson in the group; the rest are pointed at it so they are not re-billed.
|
||||
*
|
||||
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void {
|
||||
foreach ( $monthly as $group ) {
|
||||
$first = $group[0]['start'];
|
||||
$monthStart = $first->format( 'Y-m-01' );
|
||||
|
||||
// Not billable until the 1st of the lesson's month has arrived.
|
||||
if ( $monthStart > $today ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lessonIds = array_map( static fn( array $l ): int => $l['lesson_id'], $group );
|
||||
$anchorId = $lessonIds[0];
|
||||
$count = count( $group );
|
||||
|
||||
$payment = $this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$group[0]['student_id'],
|
||||
$group[0]['instructor_id'],
|
||||
$group[0]['price'] * $count,
|
||||
$group[0]['currency'],
|
||||
$group[0]['etransfer'],
|
||||
$monthStart,
|
||||
$first->format( 'Y-m' ),
|
||||
sprintf(
|
||||
/* translators: 1: offering title, 2: month, 3: number of lessons */
|
||||
_n( '%1$s (%2$s): %3$d lesson', '%1$s (%2$s): %3$d lessons', $count, 'unsupervised-schedular' ),
|
||||
$group[0]['title'],
|
||||
$first->format( 'F Y' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
|
||||
if ( null === $payment ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// createForRegistration links the anchor; point the rest of the month at
|
||||
// the same payment so the next scan sees them as billed.
|
||||
foreach ( array_slice( $lessonIds, 1 ) as $extraId ) {
|
||||
$this->bookings->setPaymentId( $extraId, (int) $payment->id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-class billing off each active enrolment's concrete session windows.
|
||||
* Weekly bills one payment per session (24h before); monthly bills one payment
|
||||
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
|
||||
* since a single enrolment maps to many periodic charges.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$offerings = [];
|
||||
|
||||
foreach ( $this->enrollments->findActiveByBillingModes( Offering::SCHEDULED_BILLING_MODES ) as $enrollment ) {
|
||||
$offeringId = $enrollment->offeringId;
|
||||
if ( ! array_key_exists( $offeringId, $offerings ) ) {
|
||||
$offerings[ $offeringId ] = $this->offerings->findById( $offeringId );
|
||||
}
|
||||
$offering = $offerings[ $offeringId ];
|
||||
if ( null === $offering || $offering->price <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$windows = $offering->sessionWindows();
|
||||
if ( [] === $windows ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
} else {
|
||||
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill one payment per group-class session that is now within 24 hours.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void {
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
$due = $start->modify( '-1 day' );
|
||||
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$periodKey = $start->format( 'Y-m-d' );
|
||||
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $periodKey ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
$enrollment->instructorId,
|
||||
$offering->price,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
$due->format( 'Y-m-d' ),
|
||||
$periodKey,
|
||||
$offering->title . ' — ' . $start->format( 'M j, Y' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
// Count this enrolment's sessions per calendar month.
|
||||
$months = [];
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
$months[ $start->format( 'Y-m' ) ] = ( $months[ $start->format( 'Y-m' ) ] ?? 0 ) + 1;
|
||||
}
|
||||
|
||||
foreach ( $months as $month => $count ) {
|
||||
$monthStart = ( new \DateTimeImmutable( $month . '-01' ) )->format( 'Y-m-d' );
|
||||
if ( $monthStart > $today ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $month ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
$enrollment->instructorId,
|
||||
$offering->price * $count,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
$monthStart,
|
||||
$month,
|
||||
sprintf(
|
||||
/* translators: 1: offering title, 2: month, 3: number of sessions */
|
||||
_n( '%1$s (%2$s): %3$d session', '%1$s (%2$s): %3$d sessions', $count, 'unsupervised-schedular' ),
|
||||
$offering->title,
|
||||
( new \DateTimeImmutable( $month . '-01' ) )->format( 'F Y' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
|
||||
* add an itemised line to the student's notice bucket and record its payment id
|
||||
* for the shared notice batch. Returns the created payment, or null when there
|
||||
* was nothing to charge.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
|
||||
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$buckets[ $studentId ][] = [
|
||||
'label' => $label,
|
||||
'amount' => $payment->total(),
|
||||
'currency' => $payment->currency,
|
||||
'due_date' => $payment->dueDate,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
];
|
||||
$batchIds[ $studentId ][] = $payment->id;
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag each student's payments with a shared batch reference and email them one
|
||||
* itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the
|
||||
* exact pending payments it covers.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function sendNotices( array $buckets, array $batchIds ): void {
|
||||
foreach ( $buckets as $studentId => $items ) {
|
||||
$reference = $this->reference();
|
||||
$this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference );
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->send( $user, $items, $reference );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, human-quotable reference shared by every payment in one student's
|
||||
* notice, printed on the email and shown in the admin payments queue.
|
||||
*/
|
||||
private function reference(): string {
|
||||
return strtoupper( substr( str_replace( '-', '', Val::string( wp_generate_uuid4() ) ), 0, 10 ) );
|
||||
}
|
||||
|
||||
private function now(): \DateTimeImmutable {
|
||||
$mysql = Val::string( current_time( 'mysql' ) );
|
||||
|
||||
return false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\ReceiptMailer;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Payment\StripeGateway;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
@@ -77,6 +79,8 @@ class Plugin {
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->register();
|
||||
|
||||
@@ -64,6 +64,7 @@ class Schema {
|
||||
term_start DATE DEFAULT NULL,
|
||||
term_end DATE DEFAULT NULL,
|
||||
class_time TIME DEFAULT NULL,
|
||||
enrollment_deadline DATE DEFAULT NULL,
|
||||
schedule_note VARCHAR(191) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) DEFAULT NULL,
|
||||
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
|
||||
@@ -158,6 +159,9 @@ class Schema {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
due_date DATE DEFAULT NULL,
|
||||
period_key VARCHAR(20) DEFAULT NULL,
|
||||
notice_batch VARCHAR(32) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) DEFAULT NULL,
|
||||
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
receipt_number VARCHAR(50) DEFAULT NULL,
|
||||
|
||||
@@ -75,9 +75,18 @@ class UpdateChecker {
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_plugins_{hostname}` filter callback. Returns the incoming
|
||||
* value untouched unless a newer release with a zip asset exists, in
|
||||
* which case it returns the update array core expects.
|
||||
* `update_plugins_{hostname}` filter callback.
|
||||
*
|
||||
* For a newer release with a zip asset, returns the update array core
|
||||
* files under the transient's `response` list (the update offer).
|
||||
* Otherwise — the plugin is current, or the release lookup failed — it
|
||||
* returns a payload with the installed version and no package, which core
|
||||
* files under `no_update`. That `no_update` entry is what sets core's
|
||||
* `update-supported` flag and makes the "Enable auto-updates" toggle
|
||||
* appear on the Plugins screen; without it, an off-directory plugin is
|
||||
* absent from the transient between releases and the toggle never shows.
|
||||
*
|
||||
* The incoming value is only passed through untouched for other plugins.
|
||||
*/
|
||||
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
|
||||
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
@@ -86,19 +95,24 @@ class UpdateChecker {
|
||||
|
||||
$release = $this->latestRelease();
|
||||
|
||||
if ( '' === $release['version'] || '' === $release['package'] ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
|
||||
return $update;
|
||||
if ( '' !== $release['version'] && '' !== $release['package']
|
||||
&& version_compare( $release['version'], USC_VERSION, '>' ) ) {
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => $release['version'],
|
||||
'url' => self::REPO_URL,
|
||||
'package' => $release['package'],
|
||||
];
|
||||
}
|
||||
|
||||
// No newer release: answer with a `no_update` payload so core keeps
|
||||
// the plugin in the update transient and shows the auto-update toggle.
|
||||
// The empty package leaves core nothing to auto-install, as intended.
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => $release['version'],
|
||||
'version' => USC_VERSION,
|
||||
'url' => self::REPO_URL,
|
||||
'package' => $release['package'],
|
||||
'package' => '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
|
||||
* @var list<array{question: string, answer: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
|
||||
* @var string $backUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lesson details', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<p><a href="<?php echo esc_url($backUrl); ?>">« <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['time']); ?>
|
||||
<?php if ('weekly' === $row['recurrence']) : ?>
|
||||
<em>(<?php esc_html_e('weekly', 'unsupervised-schedular'); ?>)</em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<?php if ($row['payment_id'] > 0) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['currency'] . ' ' . number_format($row['total'], 2)); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if ('' !== $row['notes']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($accepts)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accepts as $acceptance) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($answers)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($answers as $answer) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -6,10 +6,10 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var list<array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var 'list'|'week' $view
|
||||
* @var string $weekStart
|
||||
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var list<array{date: string, items: list<array{lesson_id: int, student: string, offering: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $baseUrl
|
||||
@@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {
|
||||
<p style="margin:0 0 8px;">
|
||||
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
|
||||
<?php echo esc_html($item['student']); ?><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em>
|
||||
<span><?php echo esc_html($item['offering']); ?></span><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em><br>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $item['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></a>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Details', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -87,6 +91,17 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<span style="color:#666;">
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['time']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
<td>
|
||||
@@ -118,6 +133,9 @@ if (! defined('ABSPATH')) {
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $row['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
|
||||
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
|
||||
* @var list<array{id: int, name: string}> $students
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
@@ -78,6 +78,12 @@ if (! defined('ABSPATH')) {
|
||||
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
|
||||
</tr>
|
||||
<?php if ('' !== $class['deadline']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['deadline']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
|
||||
@@ -130,35 +136,51 @@ if (! defined('ABSPATH')) {
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<h2><?php esc_html_e('Invite & enrol students', 'unsupervised-schedular'); ?></h2>
|
||||
<h2>
|
||||
<?php
|
||||
echo $class['invite_only']
|
||||
? esc_html__('Invite & enrol students', 'unsupervised-schedular')
|
||||
: esc_html__('Add students', 'unsupervised-schedular');
|
||||
?>
|
||||
</h2>
|
||||
<?php if (! $class['enrollment_open']) : ?>
|
||||
<p class="description"><?php esc_html_e('Enrolment has closed for this class. Students you add here are enrolled as late enrolments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif ($class['invite_only']) : ?>
|
||||
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($class['invited'])) : ?>
|
||||
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
|
||||
<ul class="ul-disc">
|
||||
<?php foreach ($class['invited'] as $invitee) : ?>
|
||||
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
|
||||
<?php if ($class['invite_only'] && ! empty($class['invited'])) : ?>
|
||||
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
|
||||
<ul class="ul-disc">
|
||||
<?php foreach ($class['invited'] as $invitee) : ?>
|
||||
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description">
|
||||
<?php
|
||||
echo $class['enrollment_open']
|
||||
? esc_html__('Enrols them now with a pending payment.', 'unsupervised-schedular')
|
||||
: esc_html__('Enrols them now with a pending payment, past the enrolment deadline.', 'unsupervised-schedular');
|
||||
?>
|
||||
</p>
|
||||
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
|
||||
<?php foreach ($students as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
|
||||
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
|
||||
<?php foreach ($students as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
</select>
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
@@ -184,6 +206,6 @@ if (! defined('ABSPATH')) {
|
||||
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,8 +85,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="billing_mode" id="billing_mode">
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>" <?php echo $editing && Offering::BILLING_ONE_TIME === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_WEEKLY); ?>" <?php echo $editing && Offering::BILLING_WEEKLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Weekly — due 24h before each lesson', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_MONTHLY); ?>" <?php echo $editing && Offering::BILLING_MONTHLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Monthly — billed on the 1st for that month\'s lessons', 'unsupervised-schedular'); ?></option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -112,6 +114,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructor’s availability.', 'unsupervised-schedular'); ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
|
||||
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
|
||||
@@ -5,13 +5,13 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}> $rows */
|
||||
/** @var list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}> $groups */
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere.', 'unsupervised-schedular'); ?></p>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere. Payments billed together on one notice are grouped under a reference — a single lump-sum e-transfer covers every payment in the group.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if (empty($rows)) : ?>
|
||||
<?php if (empty($groups)) : ?>
|
||||
<p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
@@ -26,24 +26,41 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) : ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_payment_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="mark_paid">
|
||||
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>">
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['for']); ?></td>
|
||||
<td><?php echo esc_html($row['amount']); ?></td>
|
||||
<td><?php echo esc_html($row['method']); ?></td>
|
||||
<td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td>
|
||||
<td>
|
||||
<button type="submit" class="button button-small button-primary">
|
||||
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
|
||||
</button>
|
||||
<?php foreach ($groups as $group) : ?>
|
||||
<?php if ($group['is_group']) : ?>
|
||||
<tr>
|
||||
<td colspan="6" style="background:#f0f6fc;">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: notice reference code, 2: lump-sum total, 3: number of payments. */
|
||||
esc_html__('Grouped notice %1$s — one lump-sum e-transfer of %2$s covers the %3$d payments below.', 'unsupervised-schedular'),
|
||||
'<strong>' . esc_html($group['reference']) . '</strong>',
|
||||
'<strong>' . esc_html($group['total']) . '</strong>',
|
||||
count($group['rows'])
|
||||
);
|
||||
?>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($group['rows'] as $row) : ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_payment_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="mark_paid">
|
||||
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>">
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['for']); ?></td>
|
||||
<td><?php echo esc_html($row['amount']); ?></td>
|
||||
<td><?php echo esc_html($row['method']); ?></td>
|
||||
<td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td>
|
||||
<td>
|
||||
<button type="submit" class="button button-small button-primary">
|
||||
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -383,6 +383,80 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testScheduledBillingDefersPaymentAndConfirmsLesson(): void
|
||||
{
|
||||
// Weekly/monthly offerings are billed later by the daily scan, not at
|
||||
// booking: no payment is created now, and the reserved lesson is confirmed.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, billingMode: Offering::BILLING_WEEKLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonInAlreadyBilledMonthChargesAtBooking(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson booked into June (its billing 1st
|
||||
// already reached) is an add-on and must be charged at booking, not deferred.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(
|
||||
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
|
||||
);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
|
||||
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
|
||||
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||
self::assertNotNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
||||
// so it defers to the daily scan (no payment now, lesson confirmed).
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void
|
||||
{
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
@@ -544,4 +618,22 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
|
||||
self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']);
|
||||
}
|
||||
|
||||
public function testMyLessonsIncludesBookedOfferingName(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, status: Lesson::STATUS_PENDING, id: 77);
|
||||
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 8));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano Lesson', durationMinutes: 60, id: 8)
|
||||
);
|
||||
|
||||
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
|
||||
|
||||
$data = $result->get_data();
|
||||
self::assertSame('Piano Lesson', $data[0]['offering_title']);
|
||||
self::assertSame(60, $data[0]['duration_minutes']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,41 @@ class BookingRepositoryTest extends TestCase
|
||||
self::assertSame(15, $lessons[0]->id);
|
||||
}
|
||||
|
||||
public function testFindUnbilledScheduledLessonsJoinsOfferingAndFiltersUnbilled(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/l.status != %s.*l.payment_id IS NULL.*o.billing_mode IN \( %s, %s \)/s'),
|
||||
'wp_us_lessons',
|
||||
'wp_us_availability',
|
||||
'wp_us_offerings',
|
||||
Lesson::STATUS_CANCELLED,
|
||||
'weekly',
|
||||
'monthly'
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$row = (object) [
|
||||
'id' => '15',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'offering_id' => '9',
|
||||
'start_dt' => '2026-07-15 18:00:00',
|
||||
'billing_mode' => 'weekly',
|
||||
'title' => 'Piano',
|
||||
'price' => '35.00',
|
||||
'currency' => 'CAD',
|
||||
'etransfer_email' => null,
|
||||
];
|
||||
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
||||
|
||||
$rows = $this->repo->findUnbilledScheduledLessons();
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('15', $rows[0]->id);
|
||||
}
|
||||
|
||||
public function testCountUpcomingForStudent(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
||||
|
||||
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -18,6 +20,8 @@ class LessonControllerTest extends TestCase
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('9:00 AM', $html);
|
||||
}
|
||||
|
||||
public function testListViewShowsBookedOfferingName(): void
|
||||
{
|
||||
$_GET['usc_view'] = 'list';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('lesson_id=1', $html);
|
||||
}
|
||||
|
||||
public function testLessonIdRoutesToDetailWithAnswersAndPolicies(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
$this->bookings->shouldNotReceive('findAllUpcoming');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Lesson details', $html);
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('Skill level', $html);
|
||||
self::assertStringContainsString('Beginner', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
}
|
||||
|
||||
public function testInstructorCannotOpenAnotherInstructorsLessonDetail(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('get_current_user_id')->justReturn(99);
|
||||
|
||||
// The lesson belongs to instructor 3, not the current user (99).
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->detail->shouldNotReceive('answers');
|
||||
$this->detail->shouldNotReceive('acceptances');
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('could not be found', $html);
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonDetailTest extends TestCase
|
||||
{
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private LessonDetail $detail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
|
||||
$this->detail = new LessonDetail(
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->acceptances,
|
||||
$this->policies,
|
||||
$this->versions
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
|
||||
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
|
||||
]);
|
||||
|
||||
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
|
||||
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => '#9', 'answer' => '—'],
|
||||
],
|
||||
$this->detail->answers(7)
|
||||
);
|
||||
}
|
||||
|
||||
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
|
||||
{
|
||||
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
|
||||
new PolicyAcceptance(
|
||||
policyVersionId: 4,
|
||||
studentId: 5,
|
||||
registrationType: PolicyAcceptance::REG_LESSON,
|
||||
registrationId: 7,
|
||||
ipAddress: '1.2.3.4',
|
||||
acceptedAt: '2026-07-01 10:00:00'
|
||||
),
|
||||
]);
|
||||
|
||||
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
[
|
||||
'policy' => 'Cancellation',
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
$this->detail->acceptances(7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ class EnrollmentEndpointTest extends TestCase
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
Functions\when('current_time')->justReturn('2026-07-24');
|
||||
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
@@ -108,6 +109,52 @@ class EnrollmentEndpointTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testScheduledBillingEnrollmentDefersPayment(): void
|
||||
{
|
||||
// A monthly group class is billed later by the daily scan, so enrolment
|
||||
// succeeds with no payment created now.
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 120.0, billingMode: Offering::BILLING_MONTHLY, id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(201, $result->get_status());
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testRejectsEnrollmentAfterExplicitDeadline(): void
|
||||
{
|
||||
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('enrollment_closed', $result->get_error_code());
|
||||
self::assertSame(403, $result->error_data['enrollment_closed']['status']);
|
||||
}
|
||||
|
||||
public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void
|
||||
{
|
||||
// No explicit deadline, so it defaults to term_start (the first class day),
|
||||
// which is in the past relative to the stubbed 2026-07-24 "today".
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('enrollment_closed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
||||
|
||||
@@ -108,6 +108,44 @@ class EnrollmentRepositoryTest extends TestCase
|
||||
self::assertInstanceOf(Enrollment::class, $all[0]);
|
||||
}
|
||||
|
||||
public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/e.status = %s.*o.billing_mode IN \( %s, %s \)/s'),
|
||||
'wp_us_group_enrollments',
|
||||
'wp_us_offerings',
|
||||
Enrollment::STATUS_ACTIVE,
|
||||
'weekly',
|
||||
'monthly'
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_results')->andReturn([
|
||||
(object) [
|
||||
'id' => '12',
|
||||
'offering_id' => '7',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment_id' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$found = $this->repo->findActiveByBillingModes(['weekly', 'monthly']);
|
||||
|
||||
self::assertCount(1, $found);
|
||||
self::assertInstanceOf(Enrollment::class, $found[0]);
|
||||
}
|
||||
|
||||
public function testFindActiveByBillingModesReturnsEmptyForNoModes(): void
|
||||
{
|
||||
$this->db->shouldNotReceive('prepare');
|
||||
|
||||
self::assertSame([], $this->repo->findActiveByBillingModes([]));
|
||||
}
|
||||
|
||||
public function testUpdateStatusRejectsInvalid(): void
|
||||
{
|
||||
self::assertFalse($this->repo->updateStatus(1, 'bogus'));
|
||||
|
||||
@@ -65,6 +65,7 @@ class GroupClassControllerTest extends TestCase
|
||||
static fn (string $format, string $date) => date($format, (int) strtotime($date))
|
||||
);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('current_time')->justReturn('2026-01-01');
|
||||
|
||||
$_GET = [];
|
||||
}
|
||||
@@ -183,6 +184,49 @@ class GroupClassControllerTest extends TestCase
|
||||
self::assertStringContainsString('Invite by email', $html);
|
||||
}
|
||||
|
||||
public function testClassDetailOffersDirectAddForPublicClassWithoutInviteControls(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
$_GET = ['class_id' => '8'];
|
||||
|
||||
// A plain public group class — the instructor can still add students
|
||||
// directly (a late enrolment), but the invite-only controls are absent.
|
||||
$offering = $this->offering(8, 'Choir', 10);
|
||||
|
||||
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
|
||||
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Add students directly', $html);
|
||||
self::assertStringContainsString('add_direct', $html);
|
||||
self::assertStringNotContainsString('Invite by email', $html);
|
||||
self::assertStringNotContainsString('Make available to students', $html);
|
||||
}
|
||||
|
||||
public function testClassDetailFlagsLateEnrolmentPastTheDeadline(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
// current_time is stubbed to 2026-01-01, which is past this class's deadline.
|
||||
$_GET = ['class_id' => '8'];
|
||||
|
||||
$offering = new Offering(
|
||||
instructorId: 3,
|
||||
kind: Offering::KIND_GROUP_CLASS,
|
||||
title: 'Choir',
|
||||
termStart: '2025-09-08',
|
||||
id: 8,
|
||||
);
|
||||
|
||||
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
|
||||
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('late enrolments', $html);
|
||||
self::assertStringContainsString('Add students directly', $html);
|
||||
}
|
||||
|
||||
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
|
||||
{
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
|
||||
|
||||
@@ -102,6 +102,43 @@ class OfferingControllerTest extends TestCase
|
||||
self::assertStringContainsString('2 open booking slots were removed', $html);
|
||||
}
|
||||
|
||||
public function testAddGroupClassStoresEnrollmentDeadline(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'title' => 'Ballet Beginners',
|
||||
'kind' => Offering::KIND_GROUP_CLASS,
|
||||
'term_start' => '2026-09-08',
|
||||
'enrollment_deadline' => '2026-08-31',
|
||||
];
|
||||
|
||||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Offering $o) => '2026-08-31' === $o->enrollmentDeadline
|
||||
))->andReturn(1);
|
||||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
public function testBlankEnrollmentDeadlineLeavesItNullToDefaultToFirstClass(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'title' => 'Choir',
|
||||
'kind' => Offering::KIND_GROUP_CLASS,
|
||||
'term_start' => '2026-09-08',
|
||||
];
|
||||
|
||||
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Offering $o) => null === $o->enrollmentDeadline
|
||||
))->andReturn(1);
|
||||
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
public function testGarbageClassTimeIsRejected(): void
|
||||
{
|
||||
$_POST = [
|
||||
|
||||
@@ -276,5 +276,59 @@ class OfferingTest extends TestCase
|
||||
self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS);
|
||||
self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES);
|
||||
self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES);
|
||||
self::assertContains(Offering::BILLING_WEEKLY, Offering::VALID_BILLING_MODES);
|
||||
self::assertContains(Offering::BILLING_MONTHLY, Offering::VALID_BILLING_MODES);
|
||||
}
|
||||
|
||||
public function testIsScheduledBillingOnlyForWeeklyAndMonthly(): void
|
||||
{
|
||||
self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_ONE_TIME))->isScheduledBilling());
|
||||
self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_FULL_TERM))->isScheduledBilling());
|
||||
self::assertTrue((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_WEEKLY))->isScheduledBilling());
|
||||
self::assertTrue((new Offering(1, Offering::KIND_GROUP_CLASS, 'A', billingMode: Offering::BILLING_MONTHLY))->isScheduledBilling());
|
||||
}
|
||||
|
||||
public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
|
||||
|
||||
self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline());
|
||||
}
|
||||
|
||||
public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
|
||||
|
||||
self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline());
|
||||
}
|
||||
|
||||
public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
||||
|
||||
self::assertNull($offering->effectiveEnrollmentDeadline());
|
||||
}
|
||||
|
||||
public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
|
||||
|
||||
self::assertTrue($offering->isEnrollmentOpen('2026-08-30'));
|
||||
self::assertTrue($offering->isEnrollmentOpen('2026-08-31'));
|
||||
self::assertFalse($offering->isEnrollmentOpen('2026-09-01'));
|
||||
}
|
||||
|
||||
public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
||||
|
||||
self::assertTrue($offering->isEnrollmentOpen('2099-01-01'));
|
||||
}
|
||||
|
||||
public function testToArrayIncludesEnrollmentDeadline(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10);
|
||||
|
||||
self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueMailerTest extends TestCase
|
||||
{
|
||||
private function student(string $email): \WP_User
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = $email;
|
||||
|
||||
return $student;
|
||||
}
|
||||
|
||||
public function testReturnsFalseWithoutRecipient(): void
|
||||
{
|
||||
$items = [[ 'label' => 'x', 'amount' => 10.0, 'currency' => 'CAD', 'due_date' => '2026-07-14', 'etransfer_email' => null ]];
|
||||
|
||||
self::assertFalse((new PaymentDueMailer())->send($this->student(''), $items));
|
||||
}
|
||||
|
||||
public function testReturnsFalseWithNoItems(): void
|
||||
{
|
||||
self::assertFalse((new PaymentDueMailer())->send($this->student('[email protected]'), []));
|
||||
}
|
||||
|
||||
public function testConsolidatesItemsWithGrandTotal(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static function (string $body): bool {
|
||||
return str_contains($body, 'Piano')
|
||||
&& str_contains($body, 'Jul 15, 2026')
|
||||
&& str_contains($body, 'Guitar')
|
||||
&& str_contains($body, 'Jul 22, 2026')
|
||||
&& str_contains($body, '35.00')
|
||||
&& str_contains($body, '40.00')
|
||||
// 35 + 40 grand total
|
||||
&& str_contains($body, '75.00');
|
||||
})
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [
|
||||
[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ],
|
||||
[ 'label' => 'Guitar', 'amount' => 40.0, 'currency' => 'CAD', 'due_date' => '2026-07-22', 'etransfer_email' => null ],
|
||||
];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items));
|
||||
}
|
||||
|
||||
public function testIncludesReferenceWhenProvided(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, 'REF12345'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ]];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items, 'REF12345'));
|
||||
}
|
||||
|
||||
public function testIncludesEtransferDestination(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, '[email protected]'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => '[email protected]' ]];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,68 @@ class PaymentRepositoryTest extends TestCase
|
||||
self::assertSame(50, $this->repo->insert(new Payment(5, 3, Payment::REG_LESSON, 12, 35.00)));
|
||||
}
|
||||
|
||||
public function testInsertPersistsScheduledDueDateAndPeriodKey(): void
|
||||
{
|
||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
|
||||
|
||||
$this->db->shouldReceive('insert')
|
||||
->once()
|
||||
->with(
|
||||
'wp_us_payments',
|
||||
Mockery::on(static function (array $d): bool {
|
||||
return $d['due_date'] === '2026-07-14'
|
||||
&& $d['period_key'] === '2026-07-15';
|
||||
}),
|
||||
Mockery::type('array')
|
||||
);
|
||||
$this->db->insert_id = 51;
|
||||
|
||||
self::assertSame(
|
||||
51,
|
||||
$this->repo->insert(new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, dueDate: '2026-07-14', periodKey: '2026-07-15'))
|
||||
);
|
||||
}
|
||||
|
||||
public function testExistsForPeriodReturnsTrueWhenRowFound(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d AND period_key = %s/'), 'wp_us_payments', Payment::REG_ENROLLMENT, 7, '2026-07')
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('91');
|
||||
|
||||
self::assertTrue($this->repo->existsForPeriod(Payment::REG_ENROLLMENT, 7, '2026-07'));
|
||||
}
|
||||
|
||||
public function testExistsForPeriodReturnsFalseWhenAbsent(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')->once()->andReturn('SELECT ...');
|
||||
$this->db->shouldReceive('get_var')->once()->andReturn(null);
|
||||
|
||||
self::assertFalse($this->repo->existsForPeriod(Payment::REG_ENROLLMENT, 7, '2026-08'));
|
||||
}
|
||||
|
||||
public function testAssignNoticeBatchUpdatesRows(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/SET notice_batch = %s WHERE id IN \( %d, %d \)/'), 'wp_us_payments', 'REF12345', 5, 6)
|
||||
->andReturn('UPDATE ...');
|
||||
|
||||
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(2);
|
||||
|
||||
$this->repo->assignNoticeBatch([5, 6], 'REF12345');
|
||||
}
|
||||
|
||||
public function testAssignNoticeBatchNoopForEmptyIds(): void
|
||||
{
|
||||
$this->db->shouldNotReceive('prepare');
|
||||
$this->db->shouldNotReceive('query');
|
||||
|
||||
$this->repo->assignNoticeBatch([], 'REF12345');
|
||||
}
|
||||
|
||||
public function testMarkPaidUpdatesStatusAndReceipt(): void
|
||||
{
|
||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
|
||||
|
||||
@@ -76,6 +76,17 @@ class PaymentServiceTest extends TestCase
|
||||
$this->service->voidPending(50);
|
||||
}
|
||||
|
||||
public function testVoidPendingLeavesScheduledPaymentAlone(): void
|
||||
{
|
||||
// A scheduled (weekly/monthly) payment can cover several lessons and may be
|
||||
// collected: cancelling one lesson must never void it or trigger a rebill.
|
||||
$scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60);
|
||||
$this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled);
|
||||
$this->payments->shouldNotReceive('updateStatus');
|
||||
|
||||
$this->service->voidPending(60);
|
||||
}
|
||||
|
||||
public function testVoidPendingLeavesPaidPaymentAlone(): void
|
||||
{
|
||||
// Refunds are manual: cancelling a paid lesson must not touch the ledger.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class ScheduledBillingRunnerTest extends TestCase
|
||||
{
|
||||
private PaymentService $payments;
|
||||
private BookingRepository $bookings;
|
||||
private EnrollmentRepository $enrollments;
|
||||
private OfferingRepository $offerings;
|
||||
private PaymentDueMailer $mailer;
|
||||
private ScheduledBillingRunner $runner;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->mailer = Mockery::mock(PaymentDueMailer::class);
|
||||
|
||||
// Defaults: nothing to bill unless a test says otherwise.
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([])->byDefault();
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([])->byDefault();
|
||||
$this->mailer->shouldReceive('send')->andReturn(true)->byDefault();
|
||||
$this->payments->shouldReceive('assignNoticeBatch')->byDefault();
|
||||
|
||||
Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890');
|
||||
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = '[email protected]';
|
||||
Functions\when('get_userdata')->justReturn($student);
|
||||
|
||||
$this->runner = new ScheduledBillingRunner(
|
||||
$this->payments,
|
||||
$this->bookings,
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
$this->mailer
|
||||
);
|
||||
}
|
||||
|
||||
private function now(string $mysql): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn($mysql);
|
||||
}
|
||||
|
||||
private function pending(int $id, string $due): Payment
|
||||
{
|
||||
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id);
|
||||
}
|
||||
|
||||
private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object
|
||||
{
|
||||
return (object) [
|
||||
'id' => (string) $id,
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'offering_id' => (string) $offeringId,
|
||||
'start_dt' => $start,
|
||||
'billing_mode' => $mode,
|
||||
'title' => 'Piano',
|
||||
'price' => (string) $price,
|
||||
'currency' => 'CAD',
|
||||
'etransfer_email' => '[email protected]',
|
||||
];
|
||||
}
|
||||
|
||||
public function testPrivateWeeklyBillsLessonWithin24h(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', '[email protected]', '2026-07-14', '2026-07-15')
|
||||
->andReturn($this->pending(500, '2026-07-14'));
|
||||
|
||||
$this->mailer->shouldReceive('send')->once();
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateWeeklySkipsLessonBeyond24h(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-18 18:00:00', 35.0) ]);
|
||||
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->mailer->shouldNotReceive('send');
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateMonthlyGroupsLessonsIntoOnePayment(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([
|
||||
$this->lessonRow(201, Offering::BILLING_MONTHLY, '2026-07-07 18:00:00', 30.0),
|
||||
$this->lessonRow(202, Offering::BILLING_MONTHLY, '2026-07-14 18:00:00', 30.0),
|
||||
$this->lessonRow(203, Offering::BILLING_MONTHLY, '2026-07-21 18:00:00', 30.0),
|
||||
]);
|
||||
|
||||
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', '[email protected]', '2026-07-01', '2026-07')
|
||||
->andReturn($this->pending(600, '2026-07-01'));
|
||||
|
||||
// The other two lessons are pointed at the same payment so they are not re-billed.
|
||||
$this->bookings->shouldReceive('setPaymentId')->once()->with(202, 600);
|
||||
$this->bookings->shouldReceive('setPaymentId')->once()->with(203, 600);
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateMonthlySkipsFutureMonth(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(301, Offering::BILLING_MONTHLY, '2026-08-04 18:00:00', 30.0) ]);
|
||||
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupWeeklyBillsDueSessionsOnly(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
|
||||
|
||||
// Sessions Jul 7 (due Jul 6) and Jul 14 (due Jul 13) are due by Jul 15; Jul 21 is not.
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(false);
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07')
|
||||
->andReturn($this->pending(700, '2026-07-06'));
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
|
||||
->andReturn($this->pending(701, '2026-07-13'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupWeeklyDedupSkipsExistingPeriod(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
|
||||
|
||||
// First session already billed; only the second generates a payment.
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(true);
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
|
||||
->andReturn($this->pending(701, '2026-07-13'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupMonthlyBillsMonthTotal(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
// 4 Tuesday sessions in July.
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-28'));
|
||||
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||
|
||||
// One payment: 4 sessions x 20, due on the 1st.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 80.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||
->andReturn($this->pending(800, '2026-07-01'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testCompPaymentIsNotBucketed(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
|
||||
|
||||
// A comp student's payment comes back paid — no due notice should be sent.
|
||||
$comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_COMP, Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($comp);
|
||||
|
||||
$this->mailer->shouldNotReceive('send');
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testConsolidatesAllItemsIntoOneEmailPerStudent(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-14', '2026-07-14'));
|
||||
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->andReturn(false);
|
||||
$this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-13'));
|
||||
|
||||
// Same student billed twice in one run -> exactly one email with both items,
|
||||
// and both payments tagged with one shared notice-batch reference.
|
||||
$this->payments->shouldReceive('assignNoticeBatch')
|
||||
->once()
|
||||
->with(Mockery::on(static fn (array $ids): bool => count($ids) === 2), Mockery::type('string'));
|
||||
$this->mailer->shouldReceive('send')
|
||||
->once()
|
||||
->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 2), Mockery::type('string'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
private function groupOffering(string $mode, string $termStart, string $termEnd): Offering
|
||||
{
|
||||
return new Offering(
|
||||
instructorId: 3,
|
||||
kind: Offering::KIND_GROUP_CLASS,
|
||||
title: 'Ensemble',
|
||||
price: 20.0,
|
||||
currency: 'CAD',
|
||||
billingMode: $mode,
|
||||
durationMinutes: 60,
|
||||
termStart: $termStart,
|
||||
termEnd: $termEnd,
|
||||
classTime: '16:00:00',
|
||||
id: 9,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,22 @@ class UpdateCheckerTest extends TestCase
|
||||
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload provideUpdate() returns when no newer release is offered.
|
||||
* Core files this under the transient's `no_update` list, which is what
|
||||
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
|
||||
* the test bootstrap.
|
||||
*/
|
||||
private function noUpdatePayload(): array
|
||||
{
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => '1.0.0',
|
||||
'url' => UpdateChecker::REPO_URL,
|
||||
'package' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function testRegisterHooksHostnameFilter(): void
|
||||
{
|
||||
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
|
||||
@@ -109,7 +125,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testNoUpdateWhenReleaseIsNotNewer(): void
|
||||
public function testNoUpdatePayloadWhenReleaseIsNotNewer(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -118,7 +134,9 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// Current version → core files this under `no_update` so the
|
||||
// auto-update toggle stays visible; no package to install.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testUsesCachedReleaseWithoutHittingApi(): void
|
||||
@@ -134,7 +152,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertSame('2.0.0', $result['version']);
|
||||
}
|
||||
|
||||
public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void
|
||||
public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -150,10 +168,12 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// Even with the lookup failed we still return the `no_update` payload,
|
||||
// so the auto-update toggle does not flicker away during a Gitea blip.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testNon200ResponseReturnsUpdateUnchanged(): void
|
||||
public function testNon200ResponseReturnsNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -162,7 +182,7 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testPicksFirstZipAssetAndSkipsOthers(): void
|
||||
@@ -181,7 +201,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertSame(self::PACKAGE_URL, $result['package']);
|
||||
}
|
||||
|
||||
public function testReleaseWithoutZipAssetOffersNoUpdate(): void
|
||||
public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -192,10 +212,12 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// No installable package means no update to offer, but we still keep
|
||||
// the plugin in `no_update` so the toggle shows.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testMalformedApiBodyOffersNoUpdate(): void
|
||||
public function testMalformedApiBodyOffersNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -207,6 +229,6 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.1.0
|
||||
* Version: 1.2.0
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.1.0');
|
||||
define('USC_VERSION', '1.2.0');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
@@ -35,6 +35,7 @@ register_activation_hook(__FILE__, static function (): void {
|
||||
});
|
||||
|
||||
register_deactivation_hook(__FILE__, static function (): void {
|
||||
wp_clear_scheduled_hook('us_generate_due_payments');
|
||||
flush_rewrite_rules();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user