21 Commits
Author SHA1 Message Date
thatguygriff c611268bdb Merge pull request 'Fix field-length saves, student wp-admin access, and empty instructor picker' (#111) from fix/field-length-student-admin-instructor-picker into main
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m54s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Release / Build and Publish Release (push) Successful in 2m59s
Release / Open next-version bump PR (push) Successful in 5s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #111
2026-07-24 23:27:30 +00:00
thatguygriffandClaude Opus 4.8 721c4be1d6 Fix field-length saves, student wp-admin access, and empty instructor picker
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section:

- Fixed-size fields (question labels, offering titles/notes/e-transfer
  email, policy titles/slugs) no longer silently fail to save when the
  value exceeds its column length. The REST endpoints reject over-long
  values with a 400, the admin controllers refuse to insert them, and the
  form inputs carry a maxlength so the browser blocks over-long entry.
  Limits are MAX_* constants on the value objects, kept in lockstep with
  the schema columns.

- Students are kept out of wp-admin entirely. New StudentAdminGuard
  redirects front-end-only users (no back-office capability) away from the
  dashboard and hides the admin bar for them, while administrators, studio
  admins, and instructors keep full access.

- The Add/Edit Offering instructor picker now includes WordPress
  administrators when they act as instructors (the default single-account
  setup), so a solo studio owner is selectable instead of the dropdown
  being empty.

composer test (618), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 20:22:04 -03:00
thatguygriff 3aa65bad06 Merge pull request 'Bump version to 1.2.1' (#110) from release/bump-1.2.1 into main
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 3m4s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #110
2026-07-24 19:34:30 +00:00
Release Bot f3ba09b195 Bump version to 1.2.1 and open changelog section 2026-07-24 19:34:11 +00:00
thatguygriff 771942be8b Merge pull request 'Fix invite sign-in, add customizable invite-only text, repair account questions' (#109) from fix/registration-signin-and-account-questions into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m5s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #109
2026-07-24 19:29:52 +00:00
thatguygriffandClaude Opus 4.8 242150569b Fix invite sign-in persistence, add invite-only text option, repair account questions
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
Three registration fixes reported from live use:

- Accepting an invite now keeps the student signed in. The form was
  processed inside render() during the_content, so wp_set_auth_cookie()
  ran after headers were sent and the cookie never persisted — the new
  student was bounced back to the logged-out registration page. The
  submission is now handled on template_redirect (before output) with a
  post/redirect/get, so the cookie sticks and the student lands logged in.

- The "registration is by invitation only" message is now customisable via
  a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
  falling back to the default wording when blank.

- Account-registration questions save again. dbDelta does not reliably
  relax a column from NOT NULL to NULL, so sites created before account-
  scope questions kept us_questions.offering_id NOT NULL and rejected
  account inserts ("Column 'offering_id' cannot be null"). A one-time,
  self-healing migration (guarded by its own option, not the version gate)
  re-applies the nullable definition on next load.

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 16:24:15 -03:00
thatguygriff fae1fd08ba Merge pull request 'Add group-class withdrawal deadline and kind-aware offering form' (#108) from feature/group-class-withdrawal-deadline into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m58s
CI / PHPStan (push) Successful in 3m18s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #108
2026-07-24 19:01:48 +00:00
thatguygriffandClaude Opus 4.8 2c4b481077 Add group-class withdrawal deadline and kind-aware offering form
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.

Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.

- New nullable us_offerings.withdrawal_deadline column; Offering model gains
  $withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
  (403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
  under its section, matching the scheduled-billing and credit features.

Tests: composer test (596), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:56:40 -03:00
thatguygriff f552c3952a Merge pull request 'Credit students for cancelled paid lessons' (#107) from feature/credit-cancelled-paid-lessons into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #107
2026-07-24 18:40:10 +00:00
thatguygriffandClaude Opus 4.8 e8e66eef3c Credit students for cancelled paid lessons
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.

- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
  covering payment's total; wired into all three cancel paths (student
  self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
  marking a fully-covered charge paid-by-credit; the notice shows the credit
  applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.

Ships as part of the unreleased 1.2.0 (same release as scheduled billing).

Tests: composer test (585), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:32:20 -03:00
thatguygriff cf296329a0 Merge pull request 'Consolidate changelog into 1.2.0 section ahead of release' (#106) from docs/changelog-1.2.0 into main
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 3m8s
CI / Build Plugin Zip (push) Successful in 2m50s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
Reviewed-on: #106
2026-07-24 18:14:15 +00:00
thatguygriffandClaude Opus 4.8 3f9aef7746 Consolidate changelog into 1.2.0 section
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Failing after 2m33s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
The plugin header carries 1.2.0 but CHANGELOG.md still topped out at the
untagged 1.1.3 section, and two shipped features (#104 lesson booking
detail, #105 weekly/monthly scheduled billing) were unrecorded. Neither
1.1.2 nor 1.1.3 was ever tagged, so their changes belong to the 1.2.0
release. Merge the untagged sections into a single 1.2.0 section and add
the two missing features so the release workflow publishes real notes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:55:08 -03:00
thatguygriff d1dd30dc60 Merge pull request 'Add weekly and monthly scheduled billing for offerings' (#105) from feature/weekly-monthly-billing into main
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m46s
CI / PHPStan (push) Successful in 3m16s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #105
2026-07-24 15:38:59 +00:00
thatguygriffandClaude Opus 4.8 4328e8fb5f Add weekly and monthly scheduled billing for offerings
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00
thatguygriff 36e7178158 Merge pull request 'Show booked lesson info on upcoming lists and add admin booking detail' (#104) from feature/lesson-booking-detail into main
CI / Tests (PHP 8.2) (push) Successful in 39s
CI / Coding Standards (push) Successful in 2m52s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #104
2026-07-24 14:12:33 +00:00
thatguygriffandClaude Opus 4.8 32619a1b75 Show booked lesson info on upcoming lists and add admin booking detail
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Front end: the student "upcoming lessons" panel now shows each booked
offering's name and length next to the time, and renders only the soonest
five lessons with a "Show all" reveal. GET /bookings returns offering_title
and duration_minutes so the list needs no extra request.

Admin: the Scheduler and My Lessons week/list views now show the booked
offering, and each lesson links to a detail view showing the policy versions
the student accepted (with acceptance time and IP) and their intake answers.
On My Lessons an instructor may only open their own lessons; the studio
Scheduler may open any.

composer test / composer lint / composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 11:07:06 -03:00
thatguygriff 1a447743b3 Merge pull request 'Group-class enrolment deadline with instructor late-enrolment override' (#103) from feature/group-class-enrollment-deadline into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 3m4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #103
2026-07-24 13:45:54 +00:00
thatguygriffandClaude Opus 4.8 fc7c0fa966 Show "Enrol by <date>" on the group-class card while enrolment is open
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:36:51 -03:00
thatguygriffandClaude Opus 4.8 bf29162587 Add group-class enrolment deadline with instructor late-enrolment override
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.

Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.

Bumps USC_VERSION to 1.1.3 for the schema change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:19:38 -03:00
thatguygriff 991ed2f5ad Merge pull request 'Bump version to 1.1.2' (#102) from release/bump-1.1.2 into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Coding Standards (push) Successful in 2m45s
CI / PHPStan (push) Successful in 3m15s
CI / Build Plugin Zip (push) Successful in 2m53s
Reviewed-on: #102
2026-07-24 11:56:08 +00:00
Release Bot ad2ddefebf Bump version to 1.1.2 and open changelog section 2026-07-24 11:54:08 +00:00
86 changed files with 4786 additions and 206 deletions
+26
View File
@@ -11,6 +11,32 @@ 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 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. each change under the current top section as you work.
## [1.2.1]
### Fixed
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
## [1.2.0]
### Added
- Offerings can now bill on a schedule: **weekly** (a pending payment 24 hours before each lesson) or **monthly** (one payment on the 1st for that month's lessons), alongside the existing one-time and full-term modes. Applies to both private lessons and group classes. A daily job generates due payments, and each student receives one consolidated itemised email per scan; batched payments share a reference so the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation. Cancelling a lesson never voids a scheduled payment.
- Cancelling a lesson that was **already paid for** now credits the student that money instead of leaving it as a manual refund. The credit is one lesson's share of what they paid — the whole amount for a single lesson, or a per-lesson slice of a monthly charge or a full-term series. The daily billing scan automatically applies any available credit against a student's upcoming weekly/monthly charges before emailing their notice, which shows the credit applied and the reduced total due; a charge fully covered by credit is settled and leaves the admin Payments queue. A student's outstanding credit balance is shown on their **student detail** page in the studio admin. Still-pending (unpaid) payments continue to be voided on cancellation as before.
- 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.
- Group classes now also carry a **withdrawal deadline** the instructor sets per class. Up to that day a student can withdraw themselves from the class (the group-class page shows a **Withdraw** button) — this frees their seat and voids any pending payment but does **not** credit their account. After the deadline self-withdrawal closes and the student must ask the studio, who can still withdraw them by hand from the student detail page. Leaving the deadline blank keeps self-withdrawal open indefinitely.
- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson.
- 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.
- Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any.
- The **Student Registration** block's "registration is by invitation only" message is now customisable, under a new **Invitation-only notice** panel (shortcode: `invite_only_message`). Leaving it blank keeps the default wording.
### Changed
- The student **upcoming lessons** panel now shows each booked offering's name and length beside the time, and lists only the soonest five lessons with a "Show all" reveal. The Scheduler and My Lessons week/list views likewise show the booked offering.
### Fixed
- Accepting an invitation now keeps the student signed in. Previously the registration form processed the submission after the page had started rendering, so the sign-in cookie was never sent and the new student was bounced back to the (logged-out) registration page; it is now handled before any output, and the student lands logged in.
- Account-registration questions now save. On sites first installed before account-scope questions existed, the `us_questions.offering_id` column was left `NOT NULL` (the schema migration relied on `dbDelta`, which does not reliably relax a column to allow `NULL`), so saving an account question failed with "Column 'offering_id' cannot be null". A one-time, self-healing migration relaxes the column on the next load.
## [1.1.1] ## [1.1.1]
### Fixed ### Fixed
+32
View File
@@ -46,6 +46,26 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .us-my-lesson-actions {
@@ -54,6 +74,18 @@
align-items: center; 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 { .us-cancel-lesson {
background: transparent; background: transparent;
border: 1px solid #ccc; border: 1px solid #ccc;
+25 -12
View File
@@ -5,7 +5,7 @@
const { registerBlockType } = wp.blocks; const { registerBlockType } = wp.blocks;
const { createElement: el, useState, useEffect } = wp.element; const { createElement: el, useState, useEffect } = wp.element;
const { useBlockProps, InspectorControls } = wp.blockEditor; const { useBlockProps, InspectorControls } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl } = wp.components; const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
const { useSelect } = wp.data; const { useSelect } = wp.data;
const apiFetch = wp.apiFetch; const apiFetch = wp.apiFetch;
const ServerSideRender = wp.serverSideRender; const ServerSideRender = wp.serverSideRender;
@@ -151,18 +151,31 @@
shortcode: 'us_student_register', shortcode: 'us_student_register',
attributes: { attributes: {
loginPageId: { type: 'number', default: 0 }, loginPageId: { type: 'number', default: 0 },
inviteOnlyMessage: { type: 'string', default: '' },
}, },
inspector: (attributes, setAttributes) => el( inspector: (attributes, setAttributes) => [
PanelBody, el(
{ title: __('After email confirmation', 'unsupervised-schedular') }, PanelBody,
el(PageSelect, { { title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
label: __('Sign-in page', 'unsupervised-schedular'), el(PageSelect, {
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'), label: __('Sign-in page', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'), help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
value: attributes.loginPageId, defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
onChange: (loginPageId) => setAttributes({ loginPageId }), value: attributes.loginPageId,
}) onChange: (loginPageId) => setAttributes({ loginPageId }),
), })
),
el(
PanelBody,
{ title: __('Invitation-only notice', 'unsupervised-schedular'), key: 'invite-only' },
el(TextareaControl, {
label: __('Message', 'unsupervised-schedular'),
help: __('Shown when registration is invite-only and the visitor has no valid invite link. Leave blank to use the default wording.', 'unsupervised-schedular'),
value: attributes.inviteOnlyMessage,
onChange: (inviteOnlyMessage) => setAttributes({ inviteOnlyMessage }),
})
),
],
}, },
{ {
name: 'us-scheduler/group-classes', name: 'us-scheduler/group-classes',
+38 -9
View File
@@ -388,6 +388,25 @@
return status.charAt(0).toUpperCase() + status.slice(1); 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) { function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt); const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) { if (!upcoming.length) {
@@ -395,20 +414,30 @@
return; 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 = ` myLessons.innerHTML = `
<div class="us-my-lessons"> <div class="us-my-lessons">
<h3>Your upcoming lessons</h3> <h3>Your upcoming lessons</h3>
${upcoming.map((l) => ` ${visible.map(lessonRowHtml).join('')}
<div class="us-my-lesson"> ${hidden.length ? `
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span> <div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
<span class="us-my-lesson-actions"> <button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
<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('')}
</div>`; </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) => { myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId))); btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
}); });
+55 -6
View File
@@ -103,7 +103,34 @@
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · '); return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
} }
function renderClasses(offerings, enrolledOfferingIds) { // 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;
}
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
// enrolment there is no implicit default: an unset deadline keeps withdrawal
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
function isWithdrawalOpen(o) {
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
let groups = offerings.filter((o) => o.kind === 'group_class'); let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) { if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId); groups = groups.filter((o) => Number(o.id) === singleOfferingId);
@@ -123,9 +150,17 @@
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''} ${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''} ${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p> <p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${enrolledOfferingIds.has(Number(o.id)) ${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>' ? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`} : ''}
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (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> </div>
`).join(''); `).join('');
@@ -133,6 +168,20 @@
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId); const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering)); btn.addEventListener('click', () => openEnrolment(offering));
}); });
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
});
}
function withdraw(enrollmentId) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
} }
function openEnrolment(offering) { function openEnrolment(offering) {
@@ -215,9 +264,9 @@
]) ])
.then(([offerings, enrollments]) => renderClasses( .then(([offerings, enrollments]) => renderClasses(
offerings, offerings,
new Set(enrollments new Map(enrollments
.filter((e) => e.status === 'active') .filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id))) .map((e) => [Number(e.offering_id), e.id]))
)) ))
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
+2 -1
View File
@@ -97,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite. 1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash. 2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on. 3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`. 4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode) ## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`). 1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
@@ -126,6 +126,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
## Frontend Shortcode ## Frontend Shortcode
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`. - `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
## Token Redirect ## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`) A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
+123
View File
@@ -0,0 +1,123 @@
# Feature: Student Credits (cancelled paid lessons)
## Overview
When a lesson that has **already been paid for** is cancelled, the student is
credited the amount they paid for *that lesson*. The credit sits on their account
and is automatically applied against their future scheduled-billing charges
(weekly / monthly) before they are asked to pay — so a cancelled-and-paid lesson
becomes money toward the next one rather than a manual refund.
This complements — it does not replace — the existing cancellation behaviour: a
still-**pending** payment is voided (`PaymentService::voidPending`), and only a
**paid** payment produces a credit.
## Credit amount — one lesson's share
The credit is one lesson's share of the covering payment's **total (including
tax)**:
| Covering payment | Lessons it covers | Credit on cancelling one |
|------------------|-------------------|--------------------------|
| Single booking (one-time / full-term single) | 1 | the whole total |
| Weekly **scheduled** lesson | 1 (one payment per lesson) | the whole total |
| Monthly **scheduled** charge | N lessons that month | `total ÷ N` |
| Weekly reservation **series** paid upfront (full-term) | the whole series | `total ÷ series size` |
The divisor is resolved in `PaymentService::coveredLessonCount`: a weekly series
paid upfront (an *unscheduled* payment on a lesson that has a `series_id`) divides
by the series size (`BookingRepository::countBySeries`); every other case divides
by how many lessons point at the payment (`BookingRepository::countByPaymentId`),
which is 1 for a single or weekly-scheduled lesson and N for a monthly charge.
The original payment is **left untouched** — the studio keeps the money it
collected; the credit is a forward-looking liability offset against future
billing, never a refund of past revenue.
### Guards
- Only a **paid** payment credits; an unpaid/pending one is voided instead.
- A lesson is credited **once**`CreditRepository::existsForLesson` blocks a
second credit if the same lesson is cancelled again after being reinstated.
- A non-anchor lesson in a series (no `payment_id` of its own) is credited through
the series anchor's payment.
## Applying credit at billing time
The daily scan (`Payment\ScheduledBillingRunner`) generates each student's due
payments, then — before sending the notice — applies their available credit
across those charges oldest-first (`PaymentService::applyCredits`):
- Each payment's `us_payments.credit_applied` is raised by the amount covered,
reducing what the student owes (`Payment::netDue()`).
- A payment **fully** covered by credit is marked **paid-by-credit** (status
`paid`, registration confirmed) so it drops out of the admin confirmation queue.
- A payment **partially** covered stays `pending` at its reduced net due, shown in
the admin Payments queue and on the notice.
- The credit ledger is drawn down by the total applied
(`CreditRepository::consume`, FIFO), marking each spent credit `consumed`.
The consolidated notice email (`Payment\PaymentDueMailer`) lists each charge at
its full amount, then an **"Account credit applied: -X"** line and the reduced
**Total due**. When the balance is zero the notice still goes out (so the student
knows their credit covered it) but carries no e-transfer destination or reference.
## Admin visibility
The studio admin sees a student's credit on their **student detail** page (gated by
`manage_billing`, like the payment history). An **Account credit** section shows the
available balance and a table of every credit — date, reason, original amount,
remaining, and status (`available` / `consumed`). Built by
`Auth\StudentHistory::creditBalance` / `::credits`.
## Data model — `{prefix}us_credits`
| Column | Type | Notes |
|---------------------|-----------------|---------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `student_id` | BIGINT UNSIGNED | WordPress user ID |
| `amount` | DECIMAL(10,2) | Original credit amount |
| `remaining` | DECIMAL(10,2) | Unused balance |
| `currency` | VARCHAR(3) | ISO 4217 |
| `source_payment_id` | BIGINT UNSIGNED | Payment that paid for the cancelled lesson |
| `source_lesson_id` | BIGINT UNSIGNED | The cancelled lesson (dedup key) |
| `reason` | VARCHAR(191) | Human-readable note |
| `status` | VARCHAR(20) | `available` / `consumed` |
| `created_at` | DATETIME | Insertion time |
| `updated_at` | DATETIME | Last draw-down; NULL until first consumed |
A new column on `{prefix}us_payments`:
| Column | Type | Notes |
|------------------|---------------|-----------------------------------------------------------|
| `credit_applied` | DECIMAL(10,2) | Account credit applied to this payment; `netDue = total credit_applied` |
> **Schema change:** `us_credits` and `us_payments.credit_applied` ship as part of
> the (as-yet-unreleased) **1.2.0** — the same release as scheduled billing — so
> `Installer`/`dbDelta` create them when a pre-1.2.0 site upgrades. If you are on a
> 1.2.0 *dev* build that predates this feature, the stored `us_schedular_version`
> already matches `USC_VERSION`, so `Plugin::boot()` will not re-run the installer;
> reactivate the plugin (or bump the version) to pick the new table/column up.
## Reporting caveat
Credits never touch past revenue and a credit-covered future charge is still
marked `paid`, so `PaymentReport` (which sums `status = paid`) counts the original
paid lesson and the later credit-covered lesson as gross revenue. This mirrors the
design choice to leave the original payment intact rather than represent a partial
refund of a shared payment.
## Implementation
- Model: `Unsupervised\Schedular\Payment\Credit`
- Repository: `Unsupervised\Schedular\Payment\CreditRepository`
- Issue on cancel: `PaymentService::creditForCancelledLesson`
(called from `Booking\BookingEndpoint::cancel` and `::updateStatus`)
- Apply at billing: `PaymentService::applyCredits`, driven by
`Payment\ScheduledBillingRunner::sendNotices`
- Net due: `Payment::netDue()`, `PaymentRepository::addCreditApplied`
- Lesson counts: `Booking\BookingRepository::countByPaymentId` / `countBySeries`
- Admin view: `Auth\StudentHistory::creditBalance` / `::credits`, rendered in
`templates/admin/student-detail.php`
## Tests
- `tests/Unit/Payment/CreditRepositoryTest.php`
- `tests/Unit/Payment/PaymentServiceTest.php` (`creditForCancelledLesson`, `applyCredits`)
- `tests/Unit/Payment/ScheduledBillingRunnerTest.php` (credit applied to a run)
- `tests/Unit/Payment/PaymentDueMailerTest.php` (credit line + reduced total)
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
+54 -13
View File
@@ -48,11 +48,48 @@ cancelled enrolment does not block re-enrolling).
Capacity is enforced at enrolment time by counting `active` rows for the offering; Capacity is enforced at enrolment time by counting `active` rows for the offering;
a class at capacity rejects further enrolments. 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.
## Withdrawal Flow
A student may withdraw themselves from a class they are enrolled in through the same
group-class page: an active enrolment shows a **Withdraw** button.
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
capacity seat) and voids any still-pending payment. It **never issues an account
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
for cancelled lessons; see `credits.md`).
Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructor's
`withdrawal_deadline`; see `offerings.md`). Unlike the enrolment deadline it has no
implicit default — a class with no deadline set stays open to withdrawal for its
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
idempotent.
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
which is never subject to the deadline.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------| |----------|-------------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user | | `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` | | `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/enrollments/{id}/withdraw` | Owner (the enrolled student) |
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value), `POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The `accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
@@ -74,13 +111,15 @@ flips their grant from `invited` to `enrolled`.
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per 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 person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**, which renders three controls under each invite-only **My Lessons → My Group Classes**. **Add students directly** is available on every
class: 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 1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are (`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 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 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 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. paid flow. Each is emailed a "you've been added" notice.
@@ -121,12 +160,14 @@ class becomes enrollable for them — they choose whether to enrol.
against capacity, plus a **View details** link (**View & invite** for invite-only 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 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, **details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, description, status), the roster of enrolled students with enrolment price, schedule note, enrolment deadline, status), the roster of enrolled students with
and payment status, and — for invite-only classes — an **Invite & enrol students** section enrolment and payment status, and an **Add students** section. Every class — public or
listing who has been invited but not yet enrolled alongside the add/make-available/ invite-only — carries the **Add students directly** control there, which enrols the
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning selected students immediately (a late enrolment past the deadline; the section says so
instructor). Managing who is in an invite-only class is therefore done entirely from this when the deadline has passed). Invite-only classes additionally get the
page. The summary (`templates/admin/my-group-classes.php`) and the details page **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. (`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation ## Implementation
+11 -2
View File
@@ -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. 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. 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`. 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 ## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). 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` Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via query params, same pattern as the availability page, bucketed via
`Availability\WeekCalendar`), with the original table available as the **List** `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 ## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability - `[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` - Model: `Unsupervised\Schedular\Booking\Lesson`
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment - 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 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` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` - 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
- `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/BookingRepositoryTest.php`
- `tests/Unit/Booking/LessonTest.php` - `tests/Unit/Booking/LessonTest.php`
- `tests/Unit/Booking/LessonControllerTest.php`
- `tests/Unit/Booking/LessonDetailTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php`
+34 -2
View File
@@ -15,12 +15,14 @@ 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 | | `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
| `price` | DECIMAL(10,2) | Price in dollars | | `price` | DECIMAL(10,2) | Price in dollars |
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | | `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 | | `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term |
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private | | `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise | | `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last 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 | | `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `withdrawal_deadline` | DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"| | `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`) | | `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`) | | `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -30,6 +32,12 @@ An offering is anything a student can register for: a private-lesson type (30 or
## Billing Mode ## Billing Mode
- `one_time` — charged once at booking (a single private lesson). - `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`. - `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 ## Term Dates
Group classes carry a term: `term_start` is the date of the first class and Group classes carry a term: `term_start` is the date of the first class and
@@ -51,6 +59,30 @@ one-off), and returns an empty list unless date, time, and a positive duration a
all set. These windows drive availability reconciliation (see **Instructor all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`). assignment** below and `group-classes.md`).
## Enrolment deadline
A group class carries an optional `enrollment_deadline` the instructor sets on the
offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()`
resolves it to the stored date, or to `term_start` (the first class day) when unset,
so a class with no explicit deadline still closes to new enrolments once the first
class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today"
against that effective deadline (inclusive — the deadline day is still open). The
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `group-classes.md`.
## Withdrawal deadline
A group class also carries an optional `withdrawal_deadline` — the last day a
student may withdraw *themselves* from the class. Unlike the enrolment deadline it
has **no implicit default**: `Offering::isWithdrawalOpen($today)` treats an unset
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
instructor sets a date and it passes (comparison is inclusive — the deadline day is
still open). A withdrawal made while open frees the seat and voids any still-pending
payment but **never issues an account credit** (credits are reserved for cancelled
lessons; see `credits.md`). Once the deadline passes the student must contact the
studio, and an admin withdraws them by hand from the student detail page — the admin
path is never subject to the deadline. The student endpoint enforces it
(`403 withdrawal_closed`) and the front-end group-class list mirrors the rule; see
`group-classes.md`.
## Instructor assignment ## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may (`manage_instructors`) sees an **Instructor** picker on the offering form and may
@@ -84,7 +116,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation ## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository` - 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` - Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`) - REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`) - Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
+21
View File
@@ -88,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking:
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | | `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid | | `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`) | | `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 | | `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 | | `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` | | `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
@@ -102,6 +105,24 @@ 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`. 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. 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`.
Cancelling a lesson that was **already paid** issues the student an account credit for
that lesson's share of what they paid; the next daily scan applies any available credit
against their due charges (reducing `us_payments.credit_applied``Payment::netDue()`)
before emailing the notice. See `credits.md`.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
|---------|---------------------------------------------|-----------------------------| |---------|---------------------------------------------|-----------------------------|
+1
View File
@@ -80,6 +80,7 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js` - Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php` - Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs) - Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
## Tests ## Tests
- `tests/Unit/Registration/QuestionRepositoryTest.php` - `tests/Unit/Registration/QuestionRepositoryTest.php`
+94
View File
@@ -0,0 +1,94 @@
# 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.
Cancelling a lesson that was **already paid** credits the student one lesson's share
of what they paid (`PaymentService::creditForCancelledLesson`), and the next scan
applies that credit against their due charges before emailing the notice
(`PaymentService::applyCredits`). See `credits.md` for the full model.
## 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)
+6 -1
View File
@@ -33,6 +33,10 @@ No new tables. The views are composed from existing data:
and when it was accepted. and when it was accepted.
- **Intake answers** — every registration-question answer, newest first: - **Intake answers** — every registration-question answer, newest first:
question label, answer, and the registration it was given for. question label, answer, and the registration it was given for.
- **Account credit** (`manage_billing` only) — the student's available credit
balance plus every credit (date, reason, amount, remaining, status). Credit
comes from cancelled paid lessons and is applied automatically to upcoming
scheduled billing. See `credits.md`.
- **Payment history** (`manage_billing` only) — every payment, newest first: - **Payment history** (`manage_billing` only) — every payment, newest first:
date, context, method, status, subtotal, HST, total, and receipt number. date, context, method, status, subtotal, HST, total, and receipt number.
@@ -44,7 +48,8 @@ All actions are nonce-protected POSTs handled on the detail page:
- **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path - **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path
as student-initiated cancellation: the lesson is marked `cancelled`, the as student-initiated cancellation: the lesson is marked `cancelled`, the
availability slot is freed for rebooking, and a still-pending payment is availability slot is freed for rebooking, and a still-pending payment is
voided. Paid lessons keep their payment — refunds stay a manual decision (#72). voided. A paid lesson is credited back to the student's account (see
`credits.md`) rather than refunded.
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing - **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing
its capacity seat), with the same pending-payment voiding. its capacity seat), with the same pending-payment voiding.
+5 -3
View File
@@ -17,6 +17,7 @@ use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Auth\StudentHistory; use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
@@ -24,6 +25,7 @@ use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentController; use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentReportController; use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
@@ -55,16 +57,16 @@ class AdminMenu {
private PaymentController $paymentController; private PaymentController $paymentController;
private PaymentReportController $paymentReportController; private PaymentReportController $paymentReportController;
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 ) { 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, CreditRepository $credits ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings ); $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->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer ); $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController(); $this->instructorController = new InstructorController();
$this->settings = $settings; $this->settings = $settings;
$this->accessSettings = new AccessSettings(); $this->accessSettings = new AccessSettings();
+105 -17
View File
@@ -30,6 +30,13 @@ class RegistrationPage {
*/ */
private const RESULT_CONFIRM_GROUP = 'confirm_group'; private const RESULT_CONFIRM_GROUP = 'confirm_group';
/**
* Validation error from the most recent submission processed on
* `template_redirect`, carried over to {@see render()} so it can be shown
* inline with the form. Empty when the last submit succeeded or none ran.
*/
private string $submitError = '';
public function __construct( public function __construct(
private InviteRepository $invites, private InviteRepository $invites,
private PolicyRepository $policies, private PolicyRepository $policies,
@@ -45,15 +52,29 @@ class RegistrationPage {
/** /**
* Renders the student registration shortcode output. * Renders the student registration shortcode output.
* *
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or * @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
* shortcode attributes (`login_page_id`). * `inviteOnlyMessage`) or shortcode
* attributes (`login_page_id`,
* `invite_only_message`).
*/ */
public function render( array $atts ): string { public function render( array $atts ): string {
// A just-completed invite signup is redirected back here already logged
// in (see maybeHandleSubmit); its success flag distinguishes that from a
// visitor who simply happens to be signed in already.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
if ( is_user_logged_in() ) { if ( is_user_logged_in() ) {
if ( self::RESULT_INVITE === $registered ) {
return '<div class="us-register-form"><p class="us-success">'
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
. '</p></div>';
}
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>'; return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) ); $token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
// Only the token's hash is stored, so hash the submitted token for lookup. // Only the token's hash is stored, so hash the submitted token for lookup.
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null; $invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
@@ -65,17 +86,12 @@ class RegistrationPage {
// fail to submit — the stale invite's address. // fail to submit — the stale invite's address.
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ); $inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$error = ''; // The submission itself is processed in maybeHandleSubmit on
$successType = ''; // template_redirect (before any output), so the invite auto-login cookie
// is actually sent. Its success signal returns here as ?us_registered;
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) { // only a validation error is carried on the instance to show inline.
$result = $this->handleSubmit( $invite, $open ); $successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) { $error = $this->submitError;
$successType = $result;
} else {
$error = $result;
}
}
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect). // Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
@@ -84,9 +100,10 @@ class RegistrationPage {
// Where the post-confirmation prompt sends students to sign in. // Where the post-confirmation prompt sends students to sign in.
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) ); $loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$canRegister = $open || $inviteValid; $canRegister = $open || $inviteValid;
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
// The two-step script only matters when there is a second step to reveal. // The two-step script only matters when there is a second step to reveal.
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) { if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
@@ -98,6 +115,77 @@ class RegistrationPage {
return (string) ob_get_clean(); return (string) ob_get_clean();
} }
/**
* Process a submitted registration on `template_redirect`, before any page
* output. Running here (rather than inside {@see render()}, which fires
* during `the_content` after headers are sent) is what lets the invite
* branch's `wp_set_auth_cookie()` actually persist — otherwise the student
* appears logged in for a single render and is logged out on the next view.
*
* On success the request is redirected (post/redirect/get) with a
* `?us_registered` flag so a refresh cannot resubmit; a validation error is
* stashed for {@see render()} to show inline with the form.
*/
public function maybeHandleSubmit(): void {
if ( ! isset( $_POST['us_register'] ) || is_user_logged_in() ) {
return;
}
if ( ! check_admin_referer( 'us_student_register' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer above.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled();
$result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$this->redirect( add_query_arg( 'us_registered', $result, $this->currentUrl() ) );
return;
}
$this->submitError = $result;
}
/**
* The current page's clean permalink, used as the post/redirect/get target
* so the invite token and any stale flags are dropped from the URL.
*/
private function currentUrl(): string {
$url = get_permalink();
return is_string( $url ) ? $url : home_url( '/' );
}
/**
* Issues the post-submit redirect and stops the request. Split out so tests
* can observe the target without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* The message shown when registration is closed and no valid invite is
* present. Studios can override the default via the block
* (`inviteOnlyMessage`) or shortcode (`invite_only_message`) attribute.
*
* @param array<int|string, mixed> $atts
*/
private function inviteOnlyMessage( array $atts ): string {
$custom = trim( Val::string( $atts['inviteOnlyMessage'] ?? $atts['invite_only_message'] ?? '' ) );
if ( '' !== $custom ) {
return $custom;
}
return esc_html__( 'Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular' );
}
/** /**
* Redirect to the configured registration page when an invite token lands * Redirect to the configured registration page when an invite token lands
* elsewhere (e.g. a link generated before the page was selected). Hooked on * elsewhere (e.g. a link generated before the page was selected). Hooked on
+4 -2
View File
@@ -27,8 +27,9 @@ class StudentActions {
/** /**
* Cancel a lesson on the student's behalf: marks it cancelled, frees the * Cancel a lesson on the student's behalf: marks it cancelled, frees the
* slot for rebooking, and voids a still-pending payment. Paid lessons keep * slot for rebooking, and voids a still-pending payment. A paid lesson is
* their payment — refunds are a manual, admin-side decision. * credited back to the student's account (a per-lesson share of what they
* paid) to offset their future scheduled billing.
*/ */
public function cancelLesson( int $lessonId, int $studentId ): bool { public function cancelLesson( int $lessonId, int $studentId ): bool {
$lesson = $this->bookings->findById( $lessonId ); $lesson = $this->bookings->findById( $lessonId );
@@ -40,6 +41,7 @@ class StudentActions {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED ); $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId ); $this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId ); $this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
return true; return true;
} }
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Keeps front-end-only users (students) out of wp-admin entirely.
*
* Students authenticate through the front-end login shortcode and do all of
* their work — booking, viewing lessons, paying — on the site's public pages.
* They have no reason to see the WordPress dashboard, profile screen, or admin
* bar, so this guard redirects them to the front end if they reach wp-admin and
* hides the admin bar for them everywhere.
*
* Access is decided by capability, not role: anyone holding a back-office
* capability (a WordPress administrator, studio admin, or instructor) keeps full
* wp-admin access, while a user with none of them is treated as front-end only.
*/
class StudentAdminGuard {
/**
* Capabilities that grant a genuine reason to be in wp-admin. A user holding
* none of these is front-end only and is kept out of the dashboard.
*
* @var list<string>
*/
private const BACK_OFFICE_CAPS = [
'manage_options',
RoleManager::CAP_MANAGE_INSTRUCTORS,
RoleManager::CAP_MANAGE_STUDENTS,
RoleManager::CAP_MANAGE_OFFERINGS,
RoleManager::CAP_MANAGE_QUESTIONS,
RoleManager::CAP_MANAGE_POLICIES,
RoleManager::CAP_MANAGE_BILLING,
RoleManager::CAP_MANAGE_AVAILABILITY,
RoleManager::CAP_VIEW_ALL_LESSONS,
RoleManager::CAP_VIEW_ALL_PAYMENTS,
RoleManager::CAP_VIEW_OWN_PAYMENTS,
RoleManager::CAP_EXPORT_PAYMENTS,
];
public function register(): void {
add_action( 'admin_init', [ $this, 'redirectFromDashboard' ] );
add_filter( 'show_admin_bar', [ $this, 'hideAdminBar' ] );
}
/**
* Redirect a front-end-only user away from any wp-admin page to the site
* home, so the dashboard and profile screens are never reachable.
*/
public function redirectFromDashboard(): void {
if ( ! $this->shouldBlockAdminAccess() ) {
return;
}
wp_safe_redirect( home_url( '/' ) );
exit;
}
/**
* Whether the current request into wp-admin should be bounced to the front
* end. AJAX requests are always allowed through so front-end features that
* call admin-ajax keep working.
*/
public function shouldBlockAdminAccess(): bool {
if ( wp_doing_ajax() ) {
return false;
}
if ( ! is_user_logged_in() ) {
return false;
}
return ! $this->hasBackOfficeAccess();
}
/**
* Hide the admin bar for front-end-only users; leave it untouched for anyone
* with back-office access.
*
* @param bool $show Whether WordPress would otherwise show the admin bar.
*/
public function hideAdminBar( bool $show ): bool {
if ( is_user_logged_in() && ! $this->hasBackOfficeAccess() ) {
return false;
}
return $show;
}
/**
* Whether the current user holds any capability that warrants wp-admin access.
*/
private function hasBackOfficeAccess(): bool {
foreach ( self::BACK_OFFICE_CAPS as $cap ) {
if ( current_user_can( $cap ) ) {
return true;
}
}
return false;
}
}
+13
View File
@@ -149,11 +149,24 @@ class StudentController {
$registrationInfo = $this->history->registrationInfo( (int) $student->ID ); $registrationInfo = $this->history->registrationInfo( (int) $student->ID );
$intake = $this->history->intakeAnswers( (int) $student->ID ); $intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : []; $payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
$credits = $canBilling ? $this->history->credits( (int) $student->ID ) : [];
$creditBalance = $canBilling ? $this->history->creditBalance( (int) $student->ID ) : 0.0;
$creditCurrency = $this->creditCurrency( $credits );
$backUrl = admin_url( 'admin.php?page=us-students' ); $backUrl = admin_url( 'admin.php?page=us-students' );
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php'; include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
} }
/**
* Currency to label the credit balance with — taken from the student's credits
* (they share a currency in practice), defaulting to CAD when they have none.
*
* @param list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}> $credits
*/
private function creditCurrency( array $credits ): string {
return [] !== $credits ? (string) $credits[0]['currency'] : 'CAD';
}
/** /**
* Build a display row for a lesson (slot time, offering, instructor, status). * Build a display row for a lesson (slot time, offering, instructor, status).
* *
+31
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
@@ -27,6 +29,7 @@ class StudentHistory {
private AnswerRepository $answers, private AnswerRepository $answers,
private QuestionRepository $questions, private QuestionRepository $questions,
private PaymentRepository $payments, private PaymentRepository $payments,
private CreditRepository $credits,
) {} ) {}
/** /**
@@ -129,6 +132,34 @@ class StudentHistory {
); );
} }
/**
* The student's total unused credit balance (from cancelled paid lessons),
* applied automatically against future scheduled-billing charges.
*/
public function creditBalance( int $studentId ): float {
return $this->credits->availableBalance( $studentId );
}
/**
* Every credit the student has been issued, newest first, with the amount, what
* remains, and its state.
*
* @return list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}>
*/
public function credits( int $studentId ): array {
return array_map(
static fn( Credit $credit ): array => [
'created_at' => $credit->createdAt ?? '',
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
'reason' => $credit->reason ?? '—',
'status' => $credit->status,
],
$this->credits->findByStudent( $studentId )
);
}
/** /**
* Human label for a polymorphic registration target. * Human label for a polymorphic registration target.
*/ */
+5 -1
View File
@@ -105,10 +105,14 @@ class BlockRegistrar {
'us-scheduler/student-register' => [ 'us-scheduler/student-register' => [
'render' => [ $this, 'renderRegistration' ], 'render' => [ $this, 'renderRegistration' ],
'attributes' => [ 'attributes' => [
'loginPageId' => [ 'loginPageId' => [
'type' => 'number', 'type' => 'number',
'default' => 0, 'default' => 0,
], ],
'inviteOnlyMessage' => [
'type' => 'string',
'default' => '',
],
], ],
], ],
'us-scheduler/group-classes' => [ 'us-scheduler/group-classes' => [
+51 -10
View File
@@ -123,17 +123,27 @@ class BookingEndpoint {
} }
/** /**
* A lesson's array form plus its slot's start/end times, so front-end lists * A lesson's array form plus its slot's start/end times and the booked
* can show when the session happens without a second request. * 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> * @return array<string, mixed>
*/ */
private function lessonWithTimes( Lesson $lesson ): array { 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() + [ return $lesson->toArray() + [
'start_dt' => $slot?->startDt, 'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt, 'end_dt' => $slot?->endDt,
'offering_title' => $offering?->title,
'duration_minutes' => $duration,
]; ];
} }
@@ -249,7 +259,16 @@ class BookingEndpoint {
$payment = null; $payment = null;
$status = Lesson::STATUS_PENDING; $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 // A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a // (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week. // weekly reservation cannot hold a term while paying for one week.
@@ -263,8 +282,10 @@ class BookingEndpoint {
$status = Lesson::STATUS_CONFIRMED; $status = Lesson::STATUS_CONFIRMED;
} }
} else { } else {
// Free offering: there is no payment step that would confirm these // Either a free offering, or scheduled billing (weekly / monthly) whose
// lessons later, so they are confirmed at booking time. // 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 ) { foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED ); $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
} }
@@ -296,6 +317,23 @@ class BookingEndpoint {
return $out; 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 { private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. // 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'] ?? '' ) ) ); $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
@@ -305,8 +343,9 @@ class BookingEndpoint {
/** /**
* Student-initiated cancellation of their own lesson: marks it cancelled, * Student-initiated cancellation of their own lesson: marks it cancelled,
* frees the slot for rebooking, and voids any still-pending payment. Paid * frees the slot for rebooking, and voids any still-pending payment. A lesson
* lessons keep their payment — refunds are a manual, admin-side decision. * already paid for is credited back to the student's account (a per-lesson
* share of the covering payment) to offset their future scheduled billing.
*/ */
public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( Val::int( $request->get_param( 'id' ) ) );
@@ -341,6 +380,7 @@ class BookingEndpoint {
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED ); $this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId ); $this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId ); $this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
} }
return new \WP_REST_Response( return new \WP_REST_Response(
@@ -369,6 +409,7 @@ class BookingEndpoint {
if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) { if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) {
$this->availability->release( $lesson->slotId ); $this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId ); $this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
} elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) { } elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) {
// Reinstating a cancelled lesson must re-reserve its slot, and // Reinstating a cancelled lesson must re-reserve its slot, and
// someone else may have booked the freed time in the meantime. // someone else may have booked the freed time in the meantime.
+70
View File
@@ -204,6 +204,76 @@ class BookingRepository {
return array_map( Lesson::fromRow( ... ), $rows ?? [] ); 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 ?? [];
}
/**
* How many lessons a payment covers — every lesson pointed at it, cancelled or
* not, since the payment was billed for all of them. Used to split a paid
* payment's total into a per-lesson share when one covered lesson is cancelled
* and credited. Never below zero.
*/
public function countByPaymentId( int $paymentId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE payment_id = %d',
$this->table,
$paymentId
)
);
}
/**
* How many lessons belong to a weekly series — the whole reservation an upfront
* (full-term) payment covers, so cancelling one lesson credits its per-lesson
* share. Counts every lesson in the series, cancelled or not.
*/
public function countBySeries( int $seriesId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE series_id = %d',
$this->table,
$seriesId
)
);
}
public function setPaymentId( int $id, int $paymentId ): bool { public function setPaymentId( int $id, int $paymentId ): bool {
return false !== $this->db->update( return false !== $this->db->update(
$this->table, $this->table,
+46
View File
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar; use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
@@ -17,6 +18,8 @@ class LessonController {
private BookingRepository $repository, private BookingRepository $repository,
private PaymentRepository $payments, private PaymentRepository $payments,
private AvailabilityRepository $availability, private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
) {} ) {}
public function renderAdminDashboard(): void { 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' ) ); 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 ); $this->handleEtransferUpdate( false );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); $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' ) ); 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 ); $this->handleEtransferUpdate( true );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); $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' ); $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) * Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows. * or list, plus which week the week view shows.
@@ -111,10 +152,15 @@ class LessonController {
$instructor = get_userdata( $lesson->instructorId ); $instructor = get_userdata( $lesson->instructorId );
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null; $payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
$slot = $this->availability->findById( $lesson->slotId ); $slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
return [ return [
'lesson_id' => (int) $lesson->id,
'student' => $student ? $student->display_name : (string) $lesson->studentId, 'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, '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 ) : '—', 'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '', 'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—', 'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
+73
View File
@@ -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 )
);
}
}
+66 -1
View File
@@ -59,6 +59,18 @@ class EnrollmentEndpoint {
], ],
] ]
); );
register_rest_route(
$route_namespace,
'/enrollments/(?P<id>\d+)/withdraw',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'withdraw' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
} }
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
@@ -95,6 +107,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); 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 ) { if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
@@ -123,8 +141,11 @@ class EnrollmentEndpoint {
$this->access->markEnrolled( $offeringId, $studentId ); $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; $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 ); $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
} }
@@ -139,6 +160,50 @@ class EnrollmentEndpoint {
); );
} }
/**
* Withdraw the current student from a group class they enrolled in. Allowed
* only while the offering's withdrawal deadline is open (a class with no
* deadline set stays open indefinitely); once it passes, the student must
* contact the studio and an admin withdraws them by hand. A timely withdrawal
* frees the seat and voids any still-pending payment but never issues an
* account credit — that is reserved for cancelled lessons.
*/
public function withdraw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$enrollment = $this->enrollments->findById( $id );
if ( null === $enrollment ) {
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $enrollment->studentId ) {
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
$offering = $this->offerings->findById( $enrollment->offeringId );
if ( null !== $offering && ! $offering->isWithdrawalOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error(
'withdrawal_closed',
__( 'Withdrawal for this class has closed. Please contact the studio.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
$this->enrollments->updateStatus( $id, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Enrollment::STATUS_CANCELLED,
],
200
);
}
public function isLoggedIn(): bool { public function isLoggedIn(): bool {
return is_user_logged_in(); return is_user_logged_in();
} }
+33
View File
@@ -132,6 +132,39 @@ class EnrollmentRepository {
return array_map( Enrollment::fromRow( ... ), $rows ?? [] ); 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 { public function setPaymentId( int $id, int $paymentId ): bool {
return false !== $this->db->update( return false !== $this->db->update(
$this->table, $this->table,
+18 -10
View File
@@ -176,7 +176,7 @@ class GroupClassController {
* invite-only classes — the list of people invited but not yet enrolled. * invite-only classes — the list of people invited but not yet enrolled.
* *
* @param list<Enrollment> $enrollments * @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 { private function classDetail( Offering $offering, array $enrollments ): array {
$roster = []; $roster = [];
@@ -195,16 +195,20 @@ class GroupClassController {
]; ];
} }
$deadline = $offering->effectiveEnrollmentDeadline();
return $this->classSummary( $offering, $enrollments ) + [ return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $this->instructorName( $offering ), 'instructor' => $this->instructorName( $offering ),
'price' => $offering->price, 'price' => $offering->price,
'currency' => $offering->currency, 'currency' => $offering->currency,
'duration' => $offering->durationMinutes, 'duration' => $offering->durationMinutes,
'description' => $offering->description, 'description' => $offering->description,
'schedule_note' => $offering->scheduleNote, 'schedule_note' => $offering->scheduleNote,
'active' => $offering->isActive, 'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
'roster' => $roster, 'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [], '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 * Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service). * 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 * @param list<int> $studentIds
*/ */
private function addDirect( Offering $offering, array $studentIds ): string { private function addDirect( Offering $offering, array $studentIds ): string {
+13
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
class Installer { class Installer {
@@ -12,10 +13,22 @@ class Installer {
$this->createTables(); $this->createTables();
$this->migrateData(); $this->migrateData();
( new RoleManager() )->createRoles(); ( new RoleManager() )->createRoles();
$this->scheduleBilling();
flush_rewrite_rules(); flush_rewrite_rules();
update_option( 'us_schedular_version', USC_VERSION ); 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 { private function createTables(): void {
global $wpdb; global $wpdb;
if ( ! $wpdb instanceof \wpdb ) { if ( ! $wpdb instanceof \wpdb ) {
+72 -1
View File
@@ -20,12 +20,26 @@ class Offering {
public const BILLING_ONE_TIME = 'one_time'; public const BILLING_ONE_TIME = 'one_time';
public const BILLING_FULL_TERM = 'full_term'; 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. * All valid billing modes.
* *
* @var list<string> * @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. */ /** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public'; public const ACCESS_PUBLIC = 'public';
@@ -40,6 +54,15 @@ class Offering {
*/ */
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ]; public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
public function __construct( public function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $kind, public readonly string $kind,
@@ -54,6 +77,8 @@ class Offering {
public readonly ?string $termStart = null, public readonly ?string $termStart = null,
public readonly ?string $termEnd = null, public readonly ?string $termEnd = null,
public readonly ?string $classTime = null, public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $withdrawalDeadline = null,
public readonly ?string $scheduleNote = null, public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null, public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null, public readonly ?int $cancellationCutoffHours = null,
@@ -70,6 +95,48 @@ class Offering {
return self::ACCESS_INVITE_ONLY === $this->accessMode; 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;
}
/**
* Whether a student may still withdraw themselves from this group class on
* `$today` (a `Y-m-d` date). Withdrawal stays open through the end of the
* deadline day. Unlike the enrolment deadline there is no implicit default: a
* class with no withdrawal deadline set stays open to withdrawal for its whole
* life, so the instructor must set a date to lock students in. A withdrawal
* made while open never issues an account credit — it only frees the seat and
* voids any still-pending payment.
*/
public function isWithdrawalOpen( string $today ): bool {
return null === $this->withdrawalDeadline || $today <= $this->withdrawalDeadline;
}
/** /**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is * 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 * not a real calendar date. Round-trips through DateTimeImmutable so
@@ -169,6 +236,8 @@ class Offering {
termStart: Val::stringOrNull( $row->term_start ), termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ), termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ), classTime: Val::stringOrNull( $row->class_time ?? null ),
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ), scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ), etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ), cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -203,6 +272,8 @@ class Offering {
'term_start' => $this->termStart, 'term_start' => $this->termStart,
'term_end' => $this->termEnd, 'term_end' => $this->termEnd,
'class_time' => $this->classTime, 'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'withdrawal_deadline' => $this->withdrawalDeadline,
'schedule_note' => $this->scheduleNote, 'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours, 'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode, 'access_mode' => $this->accessMode,
+41 -6
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
@@ -11,6 +12,7 @@ class OfferingController {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private ClassSlotReconciler $reconciler, private ClassSlotReconciler $reconciler,
private AccessSettings $access = new AccessSettings(),
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -137,17 +139,28 @@ class OfferingController {
} }
/** /**
* Registered instructors offered in the assignment select, by display name. * Instructors offered in the assignment select, by display name.
*
* Includes everyone holding the `us_instructor` role plus, when the site owner
* has left administrators acting as instructors (the default single-account
* setup), WordPress administrators — who teach through the dynamic capability
* grant rather than the role. Without them a solo studio owner running the
* business from an admin account would find no one to assign a class to.
* *
* @return list<array{id: int, name: string}> * @return list<array{id: int, name: string}>
*/ */
private function instructorOptions(): array { private function instructorOptions(): array {
$roles = [ RoleManager::INSTRUCTOR ];
if ( $this->access->adminsAreInstructors() ) {
$roles[] = 'administrator';
}
$users = array_filter( $users = array_filter(
get_users( get_users(
[ [
'role' => RoleManager::INSTRUCTOR, 'role__in' => $roles,
'orderby' => 'display_name', 'orderby' => 'display_name',
'order' => 'ASC', 'order' => 'ASC',
] ]
), ),
static fn( mixed $u ): bool => $u instanceof \WP_User static fn( mixed $u ): bool => $u instanceof \WP_User
@@ -184,6 +197,17 @@ class OfferingController {
return null; return null;
} }
$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'] ?? '' ) ) ) );
// Reject over-long fixed-size fields rather than let the DB silently drop them.
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
) {
return null;
}
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) ); $billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME; $billingMode = Offering::BILLING_ONE_TIME;
@@ -209,6 +233,15 @@ class OfferingController {
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) ); $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'] ?? '' ) ) ) );
// A blank (or invalid) withdrawal deadline leaves the column NULL, which
// keeps self-withdrawal open for the class's whole life
// (Offering::isWithdrawalOpen()). A set date closes it after that day.
$withdrawalDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['withdrawal_deadline'] ?? '' ) ) ) );
return new Offering( return new Offering(
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ), instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind, kind: $kind,
@@ -223,8 +256,10 @@ class OfferingController {
termStart: $termStart, termStart: $termStart,
termEnd: $termEnd, termEnd: $termEnd,
classTime: $classTime, classTime: $classTime,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), enrollmentDeadline: $enrollmentDeadline,
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ), withdrawalDeadline: $withdrawalDeadline,
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $cutoffHours, cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC, accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ), isActive: isset( $_POST['is_active'] ),
+52 -5
View File
@@ -148,6 +148,14 @@ class OfferingEndpoint {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
} }
$scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) );
$etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) );
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering( $offering = new Offering(
instructorId: get_current_user_id(), instructorId: get_current_user_id(),
kind: $kind, kind: $kind,
@@ -161,8 +169,9 @@ class OfferingEndpoint {
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ), capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
termStart: $this->nullableText( $request->get_param( 'term_start' ) ), termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ), cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ), accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
@@ -195,10 +204,19 @@ class OfferingEndpoint {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
} }
$title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title;
$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;
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering( $offering = new Offering(
instructorId: $existing->instructorId, instructorId: $existing->instructorId,
kind: $kind, kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title, title: $title,
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency, currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
billingMode: $billingMode, billingMode: $billingMode,
@@ -208,8 +226,9 @@ class OfferingEndpoint {
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity, 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, 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, termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours, cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode, accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
@@ -264,6 +283,34 @@ class OfferingEndpoint {
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] ); return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
} }
/**
* Reject any fixed-size field whose value exceeds its column length, so an
* over-long value is refused with a clear 400 rather than silently dropped
* by the database.
*/
private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error {
$fields = [
[ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ],
[ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ],
[ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ],
];
foreach ( $fields as [ $name, $value, $max ] ) {
if ( null !== $value && mb_strlen( $value ) > $max ) {
return $this->invalid(
sprintf(
/* translators: 1: field name, 2: maximum character count. */
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
$name,
$max
)
);
}
}
return null;
}
private function price( mixed $value ): float { private function price( mixed $value ): float {
return max( 0.0, Val::float( $value ) ); return max( 0.0, Val::float( $value ) );
} }
+5 -2
View File
@@ -14,12 +14,13 @@ class OfferingRepository {
/** /**
* Column formats aligned to {@see columns()} (instructor_id, kind, title, * Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly, * description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email, * capacity, term_start, term_end, class_time, enrollment_deadline,
* withdrawal_deadline, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active). * cancellation_cutoff_hours, access_mode, is_active).
* *
* @var list<string> * @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', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int { public function insert( Offering $offering ): int {
$this->db->insert( $this->db->insert(
@@ -61,6 +62,8 @@ class OfferingRepository {
'term_start' => $offering->termStart, 'term_start' => $offering->termStart,
'term_end' => $offering->termEnd, 'term_end' => $offering->termEnd,
'class_time' => $offering->classTime, 'class_time' => $offering->classTime,
'enrollment_deadline' => $offering->enrollmentDeadline,
'withdrawal_deadline' => $offering->withdrawalDeadline,
'schedule_note' => $offering->scheduleNote, 'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail, 'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours, 'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
/**
* A studio credit held on a student's account money already paid for a lesson
* that was later cancelled. Credits are consumed against future scheduled-billing
* charges (weekly / monthly) before the student is asked to pay, oldest first.
*/
class Credit {
public const STATUS_AVAILABLE = 'available';
public const STATUS_CONSUMED = 'consumed';
/**
* All valid credit statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_AVAILABLE, self::STATUS_CONSUMED ];
public function __construct(
public readonly int $studentId,
public readonly float $amount,
public readonly float $remaining,
public readonly string $currency = 'CAD',
public readonly ?int $sourcePaymentId = null,
public readonly ?int $sourceLessonId = null,
public readonly ?string $reason = null,
public readonly string $status = self::STATUS_AVAILABLE,
public readonly ?string $createdAt = null,
public readonly ?string $updatedAt = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
studentId: Val::int( $row->student_id ),
amount: Val::float( $row->amount ),
remaining: Val::float( $row->remaining ),
currency: Val::string( $row->currency ),
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ),
reason: Val::stringOrNull( $row->reason ?? null ),
status: Val::string( $row->status ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
updatedAt: Val::stringOrNull( $row->updated_at ?? null ),
id: Val::int( $row->id ),
);
}
public function isAvailable(): bool {
return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0;
}
/**
* Returns a plain array representation of the credit.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'student_id' => $this->studentId,
'amount' => $this->amount,
'remaining' => $this->remaining,
'currency' => $this->currency,
'source_payment_id' => $this->sourcePaymentId,
'source_lesson_id' => $this->sourceLessonId,
'reason' => $this->reason,
'status' => $this->status,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
class CreditRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_credits';
}
public function insert( Credit $credit ): int {
$this->db->insert(
$this->table,
[
'student_id' => $credit->studentId,
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
'source_payment_id' => $credit->sourcePaymentId,
'source_lesson_id' => $credit->sourceLessonId,
'reason' => $credit->reason,
'status' => $credit->status,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
}
public function findById( int $id ): ?Credit {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Credit::fromRow( $row ) : null;
}
/**
* Whether a credit has already been issued for a cancelled lesson, so cancelling
* (or re-cancelling) the same lesson never grants a second credit.
*/
public function existsForLesson( int $lessonId ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE source_lesson_id = %d LIMIT 1',
$this->table,
$lessonId
)
);
return null !== $found;
}
/**
* A student's total unused credit balance (sum of the remaining amounts of every
* still-available credit).
*/
public function availableBalance( int $studentId ): float {
$total = $this->db->get_var(
$this->db->prepare(
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s',
$this->table,
$studentId,
Credit::STATUS_AVAILABLE
)
);
return round( (float) $total, 2 );
}
/**
* A student's still-available credits, oldest first the FIFO order they are
* consumed in.
*
* @return list<Credit>
*/
public function findAvailableByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
$this->table,
$studentId,
Credit::STATUS_AVAILABLE
)
);
return array_map( Credit::fromRow( ... ), $rows ?? [] );
}
/**
* Every credit for a student, newest first (admin history).
*
* @return list<Credit>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( Credit::fromRow( ... ), $rows ?? [] );
}
/**
* Draw down a student's credit balance by $amount, consuming their available
* credits oldest first and marking each fully-spent credit `consumed`. Stops once
* the amount is exhausted; a balance shorter than $amount simply drains to zero.
*/
public function consume( int $studentId, float $amount ): void {
$remaining = round( $amount, 2 );
if ( $remaining <= 0.0 ) {
return;
}
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
if ( $remaining <= 0.0 ) {
break;
}
if ( null === $credit->id ) {
continue;
}
$take = min( $credit->remaining, $remaining );
$newRemaining = round( $credit->remaining - $take, 2 );
$status = $newRemaining <= 0.0 ? Credit::STATUS_CONSUMED : Credit::STATUS_AVAILABLE;
$this->db->update(
$this->table,
[
'remaining' => $newRemaining,
'status' => $status,
'updated_at' => current_time( 'mysql' ),
],
[ 'id' => $credit->id ],
[ '%f', '%s', '%s' ],
[ '%d' ]
);
$remaining = round( $remaining - $take, 2 );
}
}
}
+30
View File
@@ -44,6 +44,10 @@ class Payment {
public readonly string $status = self::STATUS_PENDING, public readonly string $status = self::STATUS_PENDING,
public readonly float $taxRate = 0.0, public readonly float $taxRate = 0.0,
public readonly float $taxAmount = 0.0, public readonly float $taxAmount = 0.0,
public readonly float $creditApplied = 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 $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null, public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
@@ -65,6 +69,10 @@ class Payment {
status: Val::string( $row->status ), status: Val::string( $row->status ),
taxRate: Val::float( $row->tax_rate ), taxRate: Val::float( $row->tax_rate ),
taxAmount: Val::float( $row->tax_amount ), taxAmount: Val::float( $row->tax_amount ),
creditApplied: Val::float( $row->credit_applied ?? 0 ),
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 ), etransferEmail: Val::stringOrNull( $row->etransfer_email ),
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ), receiptNumber: Val::stringOrNull( $row->receipt_number ),
@@ -79,6 +87,15 @@ class Payment {
return self::STATUS_PAID === $this->status; 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. * Amount billed including tax.
*/ */
@@ -86,6 +103,14 @@ class Payment {
return round( $this->amount + $this->taxAmount, 2 ); return round( $this->amount + $this->taxAmount, 2 );
} }
/**
* What the student still owes after any account credit applied to this payment.
* The full `total()` less `creditApplied`, floored at zero.
*/
public function netDue(): float {
return round( max( 0.0, $this->total() - $this->creditApplied ), 2 );
}
/** /**
* Minimal payment info embedded in registration-creation responses: enough * Minimal payment info embedded in registration-creation responses: enough
* for the front end to decide whether (and how) to run the payment step. * for the front end to decide whether (and how) to run the payment step.
@@ -117,9 +142,14 @@ class Payment {
'tax_rate' => $this->taxRate, 'tax_rate' => $this->taxRate,
'tax_amount' => $this->taxAmount, 'tax_amount' => $this->taxAmount,
'total' => $this->total(), 'total' => $this->total(),
'credit_applied' => $this->creditApplied,
'net_due' => $this->netDue(),
'currency' => $this->currency, 'currency' => $this->currency,
'method' => $this->method, 'method' => $this->method,
'status' => $this->status, 'status' => $this->status,
'due_date' => $this->dueDate,
'period_key' => $this->periodKey,
'notice_batch' => $this->noticeBatch,
'receipt_number' => $this->receiptNumber, 'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt, 'paid_at' => $this->paidAt,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
+54 -15
View File
@@ -33,22 +33,61 @@ class PaymentController {
} }
} }
$rows = array_map( $groups = $this->groupPending( $this->payments->findPending() );
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()
);
include USC_PLUGIN_DIR . 'templates/admin/payments.php'; 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 );
// Show what the student still owes — the amount less any account credit
// already applied to this payment.
$groups[ $key ]['total_raw'] += $payment->netDue();
$groups[ $key ]['rows'][] = [
'id' => (int) $payment->id,
'student' => $student ? $student->display_name : (string) $payment->studentId,
'amount' => number_format( $payment->netDue(), 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
)
);
}
} }
+107
View File
@@ -0,0 +1,107 @@
<?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
* @param float $creditApplied Account credit deducted from the total this notice covers.
* @return bool False when there is no recipient or nothing to bill.
*/
public function send( \WP_User $student, array $items, string $reference = '', float $creditApplied = 0.0 ): 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;
}
}
// Account credit (from an earlier cancelled paid lesson) offsets the total.
$creditApplied = round( min( $creditApplied, $total ), 2 );
$dueTotal = round( $total - $creditApplied, 2 );
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
. implode( "\n", $lines );
if ( $creditApplied > 0.0 ) {
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: credit amount */
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $creditApplied, 2 )
);
}
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $dueTotal, 2 )
);
if ( $dueTotal > 0.0 && [] !== $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;
}
}
+66 -1
View File
@@ -25,6 +25,10 @@ class PaymentRepository {
'status' => $payment->status, 'status' => $payment->status,
'tax_rate' => $payment->taxRate, 'tax_rate' => $payment->taxRate,
'tax_amount' => $payment->taxAmount, 'tax_amount' => $payment->taxAmount,
'credit_applied' => $payment->creditApplied,
'due_date' => $payment->dueDate,
'period_key' => $payment->periodKey,
'notice_batch' => $payment->noticeBatch,
'etransfer_email' => $payment->etransferEmail, 'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber, 'receipt_number' => $payment->receiptNumber,
@@ -32,7 +36,7 @@ class PaymentRepository {
'paid_at' => $payment->paidAt, 'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ), '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', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
@@ -74,6 +78,22 @@ class PaymentRepository {
); );
} }
/**
* Add to the account credit applied against a payment, reducing what the student
* still owes on it (`Payment::netDue()`). Accumulates, so a second application
* adds to the first.
*/
public function addCreditApplied( int $id, float $amount ): bool {
$sql = $this->db->prepare(
'UPDATE %i SET credit_applied = credit_applied + %f WHERE id = %d',
$this->table,
$amount,
$id
);
return null !== $sql && false !== $this->db->query( $sql );
}
/** /**
* Set a payment's tax rate and recompute the tax amount from its subtotal. * Set a payment's tax rate and recompute the tax amount from its subtotal.
*/ */
@@ -119,6 +139,51 @@ class PaymentRepository {
return $row ? Payment::fromRow( $row ) : null; 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 { public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( $this->db->prepare(
+162 -3
View File
@@ -21,6 +21,7 @@ class PaymentService {
private EnrollmentRepository $enrollments, private EnrollmentRepository $enrollments,
private StudioSettings $settings, private StudioSettings $settings,
private StripeGateway $stripe, private StripeGateway $stripe,
private CreditRepository $credits,
) {} ) {}
/** /**
@@ -29,8 +30,12 @@ class PaymentService {
* (card via Stripe coming soon; e-transfer confirmed manually). The * (card via Stripe coming soon; e-transfer confirmed manually). The
* e-transfer destination is frozen now from the offering override or the studio * e-transfer destination is frozen now from the offering override or the studio
* default. Returns null when the registration has no price to charge. * 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 ) { if ( $amount <= 0.0 ) {
return null; return null;
} }
@@ -58,6 +63,8 @@ class PaymentService {
status: $status, status: $status,
taxRate: $taxRate, taxRate: $taxRate,
taxAmount: $taxAmount, taxAmount: $taxAmount,
dueDate: $dueDate,
periodKey: $periodKey,
etransferEmail: $etransferEmail, etransferEmail: $etransferEmail,
) )
); );
@@ -71,6 +78,26 @@ class PaymentService {
return $this->payments->findById( $id ); 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. * Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt. * Marks it paid, confirms the registration, and emails the receipt.
@@ -92,7 +119,10 @@ class PaymentService {
/** /**
* Void the still-pending payment of a cancelled registration so it drops * Void the still-pending payment of a cancelled registration so it drops
* out of the confirmation queue. Paid payments are left alone refunds * 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 { public function voidPending( ?int $paymentId ): void {
if ( null === $paymentId ) { if ( null === $paymentId ) {
@@ -100,11 +130,140 @@ class PaymentService {
} }
$payment = $this->payments->findById( $paymentId ); $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 ); $this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
} }
} }
/**
* Credit a student for a cancelled lesson they had already paid for. The credit
* is one lesson's share of the covering payment's total (including tax) the
* whole total for a single-lesson payment, or `total ÷ lessons covered` for a
* payment that spans several (a monthly scheduled charge, or a weekly series paid
* upfront). The original payment is left untouched; the credit is applied to the
* student's future scheduled-billing charges. Returns null when the lesson was
* never paid, has no covering payment, or was already credited.
*/
public function creditForCancelledLesson( Lesson $lesson ): ?Credit {
if ( null === $lesson->id ) {
return null;
}
$paymentId = $lesson->paymentId;
if ( null === $paymentId && null !== $lesson->seriesId ) {
// Series lessons other than the anchor carry no payment_id of their own;
// the whole reservation is paid through the anchor's payment.
$anchor = $this->payments->findByRegistration( Payment::REG_LESSON, $lesson->seriesId );
$paymentId = $anchor?->id;
}
if ( null === $paymentId ) {
return null;
}
$payment = $this->payments->findById( $paymentId );
if ( null === $payment || ! $payment->isPaid() ) {
return null;
}
if ( $this->credits->existsForLesson( $lesson->id ) ) {
return null;
}
$share = round( $payment->total() / $this->coveredLessonCount( $lesson, $payment ), 2 );
if ( $share <= 0.0 ) {
return null;
}
$id = $this->credits->insert(
new Credit(
studentId: $payment->studentId,
amount: $share,
remaining: $share,
currency: $payment->currency,
sourcePaymentId: $payment->id,
sourceLessonId: $lesson->id,
reason: sprintf(
/* translators: %d: cancelled lesson id */
__( 'Credit for cancelled lesson #%d', 'unsupervised-schedular' ),
$lesson->id
),
)
);
return $this->credits->findById( $id );
}
/**
* How many lessons the covering payment was billed for, so its total can be split
* into a per-lesson credit. A weekly series paid upfront (unscheduled) covers the
* whole series; every other case a single booking, a weekly scheduled lesson
* (one payment each), or a monthly scheduled charge (payment linked to each
* lesson) is answered by how many lessons point at the payment. Never below one.
*/
private function coveredLessonCount( Lesson $lesson, Payment $payment ): int {
if ( ! $payment->isScheduled() && null !== $lesson->seriesId ) {
return max( 1, $this->bookings->countBySeries( $lesson->seriesId ) );
}
return max( 1, $this->bookings->countByPaymentId( (int) $payment->id ) );
}
/**
* Apply a student's available credit balance against a set of freshly-created
* pending payments (the ones a billing scan just generated for them), oldest
* charge first. Each payment's `credit_applied` is raised by the amount covered;
* a payment fully covered is marked paid-by-credit and its registration confirmed
* so it leaves the confirmation queue. The credit ledger is drawn down by the
* total applied. Returns a map of payment id to the credit applied to it, so the
* caller can reflect the reduction on the student's notice.
*
* @param list<Payment> $payments
* @return array<int, float>
*/
public function applyCredits( int $studentId, array $payments ): array {
$balance = $this->credits->availableBalance( $studentId );
if ( $balance <= 0.0 ) {
return [];
}
$applied = [];
$consumed = 0.0;
foreach ( $payments as $payment ) {
if ( null === $payment->id || $balance <= 0.0 ) {
continue;
}
$owing = $payment->netDue();
if ( $owing <= 0.0 ) {
continue;
}
$amount = round( min( $balance, $owing ), 2 );
if ( $amount <= 0.0 ) {
continue;
}
$this->payments->addCreditApplied( $payment->id, $amount );
// Fully covered by credit: settle it so it drops out of the pending queue.
if ( $amount >= $owing ) {
$this->payments->markPaid( $payment->id, 'USC-' . $payment->id );
$this->confirmRegistration( $payment->registrationType, $payment->registrationId );
}
$applied[ $payment->id ] = $amount;
$balance = round( $balance - $amount, 2 );
$consumed = round( $consumed + $amount, 2 );
}
if ( $consumed > 0.0 ) {
$this->credits->consume( $studentId, $consumed );
}
return $applied;
}
/** /**
* Resolve the client-side payment step for a freshly created registration. * Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed * For a card payment a Stripe PaymentIntent is created (or replayed
+382
View File
@@ -0,0 +1,382 @@
<?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. Each entry keeps
// the created payment and its label; credits are applied across the whole
// bucket before the notice is built, so a student's account credit offsets the
// run's charges oldest-first.
$buckets = [];
$this->billPrivateLessons( $now, $buckets );
$this->billGroupEnrollments( $now, $buckets );
$this->sendNotices( $buckets );
}
/**
* 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{payment: Payment, label: string}>> $buckets
*/
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): 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,
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 );
}
/**
* 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{payment: Payment, label: string}>> $buckets
*/
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): 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,
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{payment: Payment, label: string}>> $buckets
*/
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): 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 );
} else {
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets );
}
}
}
/**
* 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{payment: Payment, label: string}>> $buckets
*/
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): 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,
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{payment: Payment, label: string}>> $buckets
*/
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): 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,
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 it to the student's notice bucket with the label to show on the notice.
* Credits are applied later, once the whole bucket is known. Returns the created
* payment, or null when there was nothing to charge.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function bill( array &$buckets, 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 ][] = [
'payment' => $payment,
'label' => $label,
];
}
return $payment;
}
/**
* For each student, apply any account credit they hold against the run's charges,
* tag the payments they still owe with a shared batch reference, and email them
* one itemised notice. The notice lists each charge at its full amount, then the
* credit applied and the reduced total due; a charge fully covered by credit is
* already settled and carries no reference. A lump-sum e-transfer for the balance
* reconciles to the reference.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function sendNotices( array $buckets ): void {
foreach ( $buckets as $studentId => $entries ) {
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $studentId, $payments );
$items = [];
$batchIds = [];
$creditTotal = 0.0;
foreach ( $entries as $entry ) {
$payment = $entry['payment'];
$id = (int) $payment->id;
$credited = $applied[ $id ] ?? 0.0;
$creditTotal += $credited;
$items[] = [
'label' => $entry['label'],
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
];
// A charge still carrying a balance is what a lump-sum e-transfer covers;
// one fully settled by credit needs no reconciliation reference.
if ( round( $payment->total() - $credited, 2 ) > 0.0 ) {
$batchIds[] = $id;
}
}
$reference = [] !== $batchIds ? $this->reference() : '';
$this->payments->assignNoticeBatch( $batchIds, $reference );
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
}
}
}
/**
* 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();
}
}
+24 -6
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
@@ -18,9 +19,12 @@ use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentDueMailer;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer; use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Payment\StripeGateway; use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
@@ -48,10 +52,20 @@ class Plugin {
( new Installer() )->run(); ( new Installer() )->run();
} }
$availability = new AvailabilityRepository( $wpdb ); $availability = new AvailabilityRepository( $wpdb );
$bookings = new BookingRepository( $wpdb ); $bookings = new BookingRepository( $wpdb );
$offerings = new OfferingRepository( $wpdb ); $offerings = new OfferingRepository( $wpdb );
$questions = new QuestionRepository( $wpdb ); $questions = new QuestionRepository( $wpdb );
// One-time repair for sites where dbDelta left us_questions.offering_id
// NOT NULL (it does not reliably relax NULL-ability), which breaks
// account-scope registration questions. Guarded by its own flag rather
// than the version gate, since affected sites may already be on the
// current version. The flag is only set once the ALTER succeeds.
if ( '1' !== get_option( 'us_questions_offering_nullable', '' ) && $questions->ensureOfferingNullable() ) {
update_option( 'us_questions_offering_nullable', '1' );
}
$answers = new AnswerRepository( $wpdb ); $answers = new AnswerRepository( $wpdb );
$policies = new PolicyRepository( $wpdb ); $policies = new PolicyRepository( $wpdb );
$policyVersions = new PolicyVersionRepository( $wpdb ); $policyVersions = new PolicyVersionRepository( $wpdb );
@@ -63,10 +77,11 @@ class Plugin {
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb ); $paymentRepo = new PaymentRepository( $wpdb );
$creditRepo = new CreditRepository( $wpdb );
$settings = new StudioSettings(); $settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings ); $resolver = new BillingMethodResolver( $settings );
$stripe = new StripeGateway( $settings ); $stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe ); $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe, $creditRepo );
// The shortcode and block wrappers share the same page objects so // The shortcode and block wrappers share the same page objects so
// front-end output is identical whichever way a page embeds them. // front-end output is identical whichever way a page embeds them.
@@ -77,11 +92,14 @@ class Plugin {
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess ); $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage(); $groupClassPage = new GroupClassPage();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
( new RoleManager() )->register(); ( new RoleManager() )->register();
( new RegistrationLoginGate() )->register(); ( new RegistrationLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
+6
View File
@@ -18,6 +18,12 @@ class Policy {
*/ */
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ]; public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the slug, matching the `slug` VARCHAR(191) column. */
public const MAX_SLUG_LENGTH = 191;
public function __construct( public function __construct(
public readonly string $title, public readonly string $title,
public readonly string $slug, public readonly string $slug,
+3 -1
View File
@@ -47,7 +47,9 @@ class PolicyController {
$scope = Policy::SCOPE_BOOKING; $scope = Policy::SCOPE_BOOKING;
} }
if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) { $withinLimits = mb_strlen( $title ) <= Policy::MAX_TITLE_LENGTH && mb_strlen( $slug ) <= Policy::MAX_SLUG_LENGTH;
if ( '' !== $title && '' !== $slug && $withinLimits && null === $this->policies->findBySlug( $slug ) ) {
$this->service->createPolicy( $title, $slug, $scope ); $this->service->createPolicy( $title, $slug, $scope );
} }
+18
View File
@@ -118,12 +118,30 @@ class PolicyEndpoint {
if ( '' === $title ) { if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
} }
if ( mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy title must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_TITLE_LENGTH
)
);
}
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) ); $slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title ); $slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) { if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
} }
if ( mb_strlen( $slug ) > Policy::MAX_SLUG_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy slug must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_SLUG_LENGTH
)
);
}
if ( null !== $this->policies->findBySlug( $slug ) ) { if ( null !== $this->policies->findBySlug( $slug ) ) {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
+3
View File
@@ -12,6 +12,9 @@ class Question {
public const FIELD_SELECT = 'select'; public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox'; public const FIELD_CHECKBOX = 'checkbox';
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
public const MAX_LABEL_LENGTH = 255;
/** Question is scoped to a single offering, asked at booking/enrolment time. */ /** Question is scoped to a single offering, asked at booking/enrolment time. */
public const SCOPE_OFFERING = 'offering'; public const SCOPE_OFFERING = 'offering';
+1 -1
View File
@@ -85,7 +85,7 @@ class QuestionController {
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) ); $label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); $fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return; return;
} }
+24 -1
View File
@@ -79,6 +79,9 @@ class QuestionEndpoint {
if ( '' === $label ) { if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
} }
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT ); $fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
@@ -118,9 +121,17 @@ class QuestionEndpoint {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
} }
$label = $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label;
if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
$question = new Question( $question = new Question(
offeringId: $existing->offeringId, offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label, label: $label,
fieldType: $fieldType, fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options, options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired, isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
@@ -217,4 +228,16 @@ class QuestionEndpoint {
private function invalid( string $message ): \WP_Error { private function invalid( string $message ): \WP_Error {
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] ); return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
} }
/**
* Build a uniform "too long" validation message for a named field.
*/
private function tooLongMessage( string $field, int $max ): string {
return sprintf(
/* translators: 1: field name, 2: maximum character count. */
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
$field,
$max
);
}
} }
+22
View File
@@ -106,4 +106,26 @@ class QuestionRepository {
[ '%d' ] [ '%d' ]
); );
} }
/**
* Relax `offering_id` to allow NULL for account-scope questions (which are
* not tied to an offering).
*
* The account-questions feature (v1.1.0) made the column nullable in the
* schema, but dbDelta does not reliably change a column from NOT NULL to
* NULL, so sites created before then keep the old NOT NULL column and reject
* account-scope inserts with "Column 'offering_id' cannot be null". This
* MODIFY is idempotent re-applying the nullable definition is a no-op.
*
* @return bool True when the statement ran (or was already applied), false
* if it could not be prepared or the query failed.
*/
public function ensureOfferingNullable(): bool {
$sql = $this->db->prepare(
'ALTER TABLE %i MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL',
$this->table
);
return null !== $sql && false !== $this->db->query( $sql );
}
} }
+24
View File
@@ -64,6 +64,8 @@ class Schema {
term_start DATE DEFAULT NULL, term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL, term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL, class_time TIME DEFAULT NULL,
enrollment_deadline DATE DEFAULT NULL,
withdrawal_deadline DATE DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL, cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
@@ -158,6 +160,10 @@ class Schema {
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0, tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
credit_applied 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, etransfer_email VARCHAR(191) DEFAULT NULL,
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL,
@@ -171,6 +177,24 @@ class Schema {
KEY status (status) KEY status (status)
) {$charset};", ) {$charset};",
"CREATE TABLE {$prefix}us_credits (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
student_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
remaining DECIMAL(10,2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
source_payment_id BIGINT UNSIGNED DEFAULT NULL,
source_lesson_id BIGINT UNSIGNED DEFAULT NULL,
reason VARCHAR(191) DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY student_id (student_id),
KEY status (status),
KEY source_lesson_id (source_lesson_id)
) {$charset};",
"CREATE TABLE {$prefix}us_group_enrollments ( "CREATE TABLE {$prefix}us_group_enrollments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL, offering_id BIGINT UNSIGNED NOT NULL,
+3
View File
@@ -23,6 +23,9 @@ class ShortcodeRegistrar {
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) ); add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) ); add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) ); add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
// Process registration submissions before output so the invite branch's
// auth cookie is actually sent (render() runs too late, during the_content).
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] ); add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
} }
+121
View File
@@ -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); ?>">&laquo; <?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>
+21 -3
View File
@@ -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 'list'|'week' $view
* @var string $weekStart * @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 $prevWeek
* @var string $nextWeek * @var string $nextWeek
* @var string $baseUrl * @var string $baseUrl
@@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {
<p style="margin:0 0 8px;"> <p style="margin:0 0 8px;">
<strong><?php echo esc_html($item['time_short']); ?></strong><br> <strong><?php echo esc_html($item['time_short']); ?></strong><br>
<?php echo esc_html($item['student']); ?><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> </p>
<?php endforeach; ?> <?php endforeach; ?>
</td> </td>
@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) {
<tr> <tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', '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('Date/Time', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', '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('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', '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('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Details', 'unsupervised-schedular'); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -87,6 +91,17 @@ if (! defined('ABSPATH')) {
<tr> <tr>
<td><?php echo esc_html($row['student']); ?></td> <td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['instructor']); ?></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['time']); ?></td>
<td><?php echo esc_html($row['status']); ?></td> <td><?php echo esc_html($row['status']); ?></td>
<td> <td>
@@ -118,6 +133,9 @@ if (! defined('ABSPATH')) {
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?php echo esc_html($row['notes']); ?></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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+50 -28
View File
@@ -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 list<array{id: int, name: string}> $students
* @var string $notice * @var string $notice
* @var string $baseUrl * @var string $baseUrl
@@ -78,6 +78,12 @@ if (! defined('ABSPATH')) {
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th> <th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td> <td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
</tr> </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']) : ?> <?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
<tr> <tr>
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th> <th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
@@ -130,35 +136,51 @@ if (! defined('ABSPATH')) {
</table> </table>
<?php endif; ?> <?php endif; ?>
<?php if ($class['invite_only']) : ?> <h2>
<h2><?php esc_html_e('Invite &amp; enrol students', 'unsupervised-schedular'); ?></h2> <?php
echo $class['invite_only']
? esc_html__('Invite &amp; 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> <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'])) : ?> <?php if ($class['invite_only'] && ! empty($class['invited'])) : ?>
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3> <h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
<ul class="ul-disc"> <ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?> <?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li> <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; ?> <?php endforeach; ?>
</ul> </select>
<?php endif; ?> <p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;"> </p>
<form method="post"> </form>
<?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>
<?php if ($class['invite_only']) : ?>
<form method="post"> <form method="post">
<?php wp_nonce_field('usc_group_action'); ?> <?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>"> <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> <button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p> </p>
</form> </form>
</div> <?php endif; ?>
<?php endif; ?> </div>
</div> </div>
+46 -11
View File
@@ -45,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th> <th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td> <td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_TITLE_LENGTH); ?>" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
</tr> </tr>
<tr> <tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th> <th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
@@ -85,34 +85,50 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th> <th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
<td> <td>
<select name="billing_mode" id="billing_mode"> <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_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> </select>
</td> </td>
</tr> </tr>
<tr> <tr class="us-private-only">
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td> <td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
</tr> </tr>
<tr> <tr class="us-group-only">
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th> <th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td> <td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
</tr> </tr>
<tr> <tr class="us-group-only">
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th> <th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
<td> <td>
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>"> <input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span> <span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td> </td>
</tr> </tr>
<tr> <tr class="us-group-only">
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th> <th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
<td> <td>
<input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>"> <input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>">
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span> <span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td> </td>
</tr> </tr>
<tr> <tr class="us-group-only">
<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 class="us-group-only">
<th><label for="withdrawal_deadline"><?php esc_html_e('Withdrawal deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="withdrawal_deadline" id="withdrawal_deadline" value="<?php echo esc_attr($editing->withdrawalDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day a student may withdraw themselves. A withdrawal on or before this day frees the seat and voids any pending payment without crediting the student; after it, students can no longer withdraw online. Leave blank to allow withdrawal any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr class="us-group-only">
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td> <td>
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label> <label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
@@ -122,13 +138,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr class="us-group-only">
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th> <th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td> <td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_SCHEDULE_NOTE_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
</tr> </tr>
<tr> <tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th> <th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td> <td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_ETRANSFER_EMAIL_LENGTH); ?>" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
</tr> </tr>
<tr> <tr>
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th> <th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
@@ -137,7 +153,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr class="us-group-only">
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
<td> <td>
<label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label> <label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label>
@@ -155,6 +171,25 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<?php endif; ?> <?php endif; ?>
</form> </form>
<?php // Progressive enhancement: only show the fields relevant to the chosen
// kind. Without JS every row stays visible (the pre-toggle behaviour), so
// the form is fully usable either way. ?>
<script>
(function () {
var kind = document.getElementById('kind');
if (!kind) return;
var groupOnly = document.querySelectorAll('.us-group-only');
var privateOnly = document.querySelectorAll('.us-private-only');
function sync() {
var isGroup = kind.value === '<?php echo esc_js(Offering::KIND_GROUP_CLASS); ?>';
groupOnly.forEach(function (row) { row.style.display = isGroup ? '' : 'none'; });
privateOnly.forEach(function (row) { row.style.display = isGroup ? 'none' : ''; });
}
kind.addEventListener('change', sync);
sync();
}());
</script>
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
<?php if (empty($offerings)) : ?> <?php if (empty($offerings)) : ?>
+37 -20
View File
@@ -5,13 +5,13 @@ if (! defined('ABSPATH')) {
exit; 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"> <div class="wrap">
<h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1> <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> <p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
@@ -26,24 +26,41 @@ if (! defined('ABSPATH')) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($rows as $row) : ?> <?php foreach ($groups as $group) : ?>
<tr> <?php if ($group['is_group']) : ?>
<form method="post"> <tr>
<?php wp_nonce_field('usc_payment_action'); ?> <td colspan="6" style="background:#f0f6fc;">
<input type="hidden" name="usc_action" value="mark_paid"> <?php
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>"> printf(
<td><?php echo esc_html($row['student']); ?></td> /* translators: 1: notice reference code, 2: lump-sum total, 3: number of payments. */
<td><?php echo esc_html($row['for']); ?></td> esc_html__('Grouped notice %1$s — one lump-sum e-transfer of %2$s covers the %3$d payments below.', 'unsupervised-schedular'),
<td><?php echo esc_html($row['amount']); ?></td> '<strong>' . esc_html($group['reference']) . '</strong>',
<td><?php echo esc_html($row['method']); ?></td> '<strong>' . esc_html($group['total']) . '</strong>',
<td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td> count($group['rows'])
<td> );
<button type="submit" class="button button-small button-primary"> ?>
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
</button>
</td> </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; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
+2 -2
View File
@@ -24,12 +24,12 @@ if (! defined('ABSPATH')) {
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th> <th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="title" id="title" class="regular-text" required></td> <td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" required></td>
</tr> </tr>
<tr> <tr>
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th> <th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
<td> <td>
<input type="text" name="slug" id="slug" class="regular-text" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>"> <input type="text" name="slug" id="slug" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_SLUG_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
</td> </td>
</tr> </tr>
<tr> <tr>
+1 -1
View File
@@ -53,7 +53,7 @@ if (! defined('ABSPATH')) {
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th> <th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="label" id="label" class="regular-text" required></td> <td><input type="text" name="label" id="label" class="regular-text" maxlength="<?php echo esc_attr((string) Question::MAX_LABEL_LENGTH); ?>" required></td>
</tr> </tr>
<tr> <tr>
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th> <th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
+39
View File
@@ -14,6 +14,9 @@ if (! defined('ABSPATH')) {
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo * @var list<array{question: string, answer: string, required: bool}> $registrationInfo
* @var list<array{question: string, answer: string, context: string}> $intake * @var list<array{question: string, answer: string, context: string}> $intake
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments * @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
* @var list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}> $credits
* @var float $creditBalance
* @var string $creditCurrency
* @var string $backUrl * @var string $backUrl
* @var bool $canBilling * @var bool $canBilling
* @var string $billingOverride * @var string $billingOverride
@@ -241,6 +244,42 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php endif; ?> <?php endif; ?>
<?php if ($canBilling) : ?> <?php if ($canBilling) : ?>
<h2><?php esc_html_e('Account credit', 'unsupervised-schedular'); ?></h2>
<p>
<?php
printf(
/* translators: %s: available credit balance, e.g. "45.00 CAD" */
esc_html__('Available balance: %s', 'unsupervised-schedular'),
'<strong>' . esc_html(number_format_i18n($creditBalance, 2) . ' ' . $creditCurrency) . '</strong>'
);
?>
<span class="description"><?php esc_html_e('Credit from cancelled paid lessons is applied automatically to upcoming scheduled billing.', 'unsupervised-schedular'); ?></span>
</p>
<?php if (! empty($credits)) : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Date', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Reason', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Amount', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Remaining', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($credits as $credit) : ?>
<tr>
<td><?php echo esc_html($credit['created_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $credit['created_at']) : '—'); ?></td>
<td><?php echo esc_html($credit['reason']); ?></td>
<td><?php echo esc_html(number_format_i18n($credit['amount'], 2) . ' ' . $credit['currency']); ?></td>
<td><?php echo esc_html(number_format_i18n($credit['remaining'], 2) . ' ' . $credit['currency']); ?></td>
<td><?php echo esc_html($credit['status']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Payment history', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Payment history', 'unsupervised-schedular'); ?></h2>
<?php if (empty($payments)) : ?> <?php if (empty($payments)) : ?>
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
+2 -1
View File
@@ -12,6 +12,7 @@ if (! defined('ABSPATH')) {
* @var bool $inviteValid Whether $invite can still be redeemed only then is the email fixed. * @var bool $inviteValid Whether $invite can still be redeemed only then is the email fixed.
* @var string $token Raw invite token from the request (only its hash is stored). * @var string $token Raw invite token from the request (only its hash is stored).
* @var bool $canRegister * @var bool $canRegister
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
* @var bool $open Whether open (self-approval) registration is enabled. * @var bool $open Whether open (self-approval) registration is enabled.
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm). * @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'. * @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
@@ -74,7 +75,7 @@ $renderQuestionField = static function (Question $question): void {
<?php endif; ?> <?php endif; ?>
<?php if (! $canRegister) : ?> <?php if (! $canRegister) : ?>
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p> <p><?php echo esc_html($inviteOnlyMessage); ?></p>
<?php else : ?> <?php else : ?>
<?php if ($error !== '') : ?> <?php if ($error !== '') : ?>
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p> <p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
+99 -2
View File
@@ -59,11 +59,14 @@ class RegistrationPageTest extends TestCase
'settings' => Mockery::mock(StudioSettings::class), 'settings' => Mockery::mock(StudioSettings::class),
]; ];
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['page'] = new RegistrationPage( $this->ctx['page'] = new RegistrationPage(
$invites, $invites,
$policies, $policies,
Mockery::mock(PolicyVersionRepository::class), $this->ctx['versions'],
Mockery::mock(AcceptanceRepository::class), $this->ctx['acceptances'],
$this->ctx['settings'], $this->ctx['settings'],
$this->ctx['mailer'], $this->ctx['mailer'],
$questions, $questions,
@@ -403,4 +406,98 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit($invite, false)); self::assertSame('invite', $this->submit($invite, false));
} }
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('get_permalink')->justReturn('http://home.test/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
// The cookie must be set here — during template_redirect, before output —
// which is the whole point of processing the submit outside render().
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$page = Mockery::mock(
RegistrationPage::class,
[
$this->ctx['invites'],
$this->ctx['policies'],
$this->ctx['versions'],
$this->ctx['acceptances'],
$this->ctx['settings'],
$this->ctx['mailer'],
$this->ctx['questions'],
$this->ctx['answers'],
$this->ctx['access'],
]
)->makePartial()->shouldAllowMockingProtectedMethods();
$captured = '';
$page->shouldReceive('redirect')->once()->with(Mockery::on(static function (string $url) use (&$captured): bool {
$captured = $url;
return true;
}));
$page->maybeHandleSubmit();
self::assertStringContainsString('us_registered=invite', $captured);
}
public function testMaybeHandleSubmitStoresValidationErrorWithoutRedirecting(): void
{
// Too-short password: handleSubmit returns an error and no redirect fires.
$_POST = [ 'us_register' => '1', 'password' => 'short', 'display_name' => 'Ada' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
// A redirect would call exit; reaching the assertion proves none happened.
$this->ctx['page']->maybeHandleSubmit();
$error = (new \ReflectionProperty(RegistrationPage::class, 'submitError'))->getValue($this->ctx['page']);
self::assertNotSame('', $error);
}
public function testInviteSuccessRedirectShowsLoggedInWelcome(): void
{
// After the PRG redirect the student is logged in; the us_registered flag
// distinguishes a just-completed signup from an already-logged-in visitor.
$_GET = [ 'us_registered' => 'invite' ];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('us-success', $html);
self::assertStringContainsString('now logged in', $html);
}
public function testInviteOnlyMessageCanBeCustomised(): void
{
// Closed registration and no invite → the invitation-only gate shows.
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
Functions\when('wp_nonce_field')->justReturn('');
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$html = $this->ctx['page']->render([ 'inviteOnlyMessage' => 'Ask the front desk for a link.' ]);
self::assertStringContainsString('Ask the front desk for a link.', $html);
self::assertStringNotContainsString('by invitation only', $html);
}
} }
+4
View File
@@ -42,6 +42,10 @@ class StudentActionsTest extends TestCase
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true); $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true); $this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(40); $this->payments->shouldReceive('voidPending')->once()->with(40);
// A paid lesson is credited; the cancelled lesson value object is handed over.
$this->payments->shouldReceive('creditForCancelledLesson')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 12 && $l->paymentId === 40));
self::assertTrue($this->actions->cancelLesson(12, 5)); self::assertTrue($this->actions->cancelLesson(12, 5));
} }
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class StudentAdminGuardTest extends TestCase
{
private StudentAdminGuard $guard;
protected function setUp(): void
{
parent::setUp();
$this->guard = new StudentAdminGuard();
Functions\when('wp_doing_ajax')->justReturn(false);
}
/**
* @param list<string> $held Capabilities the user is treated as holding.
*/
private function stubUser(bool $loggedIn, array $held = []): void
{
Functions\when('is_user_logged_in')->justReturn($loggedIn);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => in_array($cap, $held, true));
}
public function testBlocksStudentWithNoBackOfficeCapabilities(): void
{
// A student holds only front-end capabilities.
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON, RoleManager::CAP_VIEW_LESSONS]);
self::assertTrue($this->guard->shouldBlockAdminAccess());
}
public function testAllowsInstructor(): void
{
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testAllowsAdministrator(): void
{
$this->stubUser(true, ['manage_options']);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testDoesNotBlockLoggedOutRequests(): void
{
$this->stubUser(false);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testDoesNotBlockAjaxRequests(): void
{
Functions\when('wp_doing_ajax')->justReturn(true);
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testHidesAdminBarForStudent(): void
{
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
self::assertFalse($this->guard->hideAdminBar(true));
}
public function testKeepsAdminBarForInstructor(): void
{
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
self::assertTrue($this->guard->hideAdminBar(true));
}
public function testLeavesAdminBarUntouchedForLoggedOutVisitor(): void
{
$this->stubUser(false);
self::assertFalse($this->guard->hideAdminBar(false));
}
}
+36 -1
View File
@@ -5,6 +5,8 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\StudentHistory; use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
@@ -27,6 +29,7 @@ class StudentHistoryTest extends TestCase
private AnswerRepository&Mockery\MockInterface $answers; private AnswerRepository&Mockery\MockInterface $answers;
private QuestionRepository&Mockery\MockInterface $questions; private QuestionRepository&Mockery\MockInterface $questions;
private PaymentRepository&Mockery\MockInterface $payments; private PaymentRepository&Mockery\MockInterface $payments;
private CreditRepository&Mockery\MockInterface $credits;
private StudentHistory $history; private StudentHistory $history;
protected function setUp(): void protected function setUp(): void
@@ -39,6 +42,7 @@ class StudentHistoryTest extends TestCase
$this->answers = Mockery::mock(AnswerRepository::class); $this->answers = Mockery::mock(AnswerRepository::class);
$this->questions = Mockery::mock(QuestionRepository::class); $this->questions = Mockery::mock(QuestionRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class); $this->payments = Mockery::mock(PaymentRepository::class);
$this->credits = Mockery::mock(CreditRepository::class);
$this->history = new StudentHistory( $this->history = new StudentHistory(
$this->acceptances, $this->acceptances,
@@ -46,7 +50,8 @@ class StudentHistoryTest extends TestCase
$this->policyVersions, $this->policyVersions,
$this->answers, $this->answers,
$this->questions, $this->questions,
$this->payments $this->payments,
$this->credits
); );
} }
@@ -215,4 +220,34 @@ class StudentHistoryTest extends TestCase
self::assertSame('Enrolment #3', $rows[0]['context']); self::assertSame('Enrolment #3', $rows[0]['context']);
self::assertSame('—', $rows[0]['receipt']); self::assertSame('—', $rows[0]['receipt']);
} }
public function testCreditBalanceDelegatesToRepository(): void
{
$this->credits->shouldReceive('availableBalance')->once()->with(5)->andReturn(45.0);
self::assertSame(45.0, $this->history->creditBalance(5));
}
public function testCreditsBuildDisplayRows(): void
{
$this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([
new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300),
]);
$rows = $this->history->credits(5);
self::assertSame(
[
[
'created_at' => '2026-07-01 09:00:00',
'amount' => 33.00,
'remaining' => 13.00,
'currency' => 'CAD',
'reason' => 'Credit for cancelled lesson #77',
'status' => Credit::STATUS_AVAILABLE,
],
],
$rows
);
}
} }
+1 -1
View File
@@ -126,7 +126,7 @@ class BlockRegistrarTest extends TestCase
array_keys($registered['us-scheduler/student-login']['attributes']) array_keys($registered['us-scheduler/student-login']['attributes'])
); );
self::assertSame( self::assertSame(
['loginPageId'], ['loginPageId', 'inviteOnlyMessage'],
array_keys($registered['us-scheduler/student-register']['attributes']) array_keys($registered['us-scheduler/student-register']['attributes'])
); );
self::assertSame( self::assertSame(
+113
View File
@@ -48,6 +48,9 @@ class BookingEndpointTest extends TestCase
$this->payments = Mockery::mock(PaymentService::class); $this->payments = Mockery::mock(PaymentService::class);
$this->settings = Mockery::mock(StudioSettings::class); $this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault(); $this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault();
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
// cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
$this->endpoint = new BookingEndpoint( $this->endpoint = new BookingEndpoint(
$this->availability, $this->availability,
@@ -383,6 +386,80 @@ class BookingEndpointTest extends TestCase
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); 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 public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void
{ {
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
@@ -398,6 +475,24 @@ class BookingEndpointTest extends TestCase
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']); self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
} }
public function testCancelCreditsThePaidLesson(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
// The cancelled lesson (the value object, so its payment_id is intact) is
// handed to the credit path.
$this->payments->shouldReceive('creditForCancelledLesson')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 77 && $l->paymentId === 12));
$this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
}
public function testCancelWithinStudioCutoffIsRejected(): void public function testCancelWithinStudioCutoffIsRejected(): void
{ {
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00, // Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
@@ -544,4 +639,22 @@ class BookingEndpointTest extends TestCase
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']); self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
self::assertSame('2026-07-01 11:00:00', $data[0]['end_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); 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 public function testCountUpcomingForStudent(): void
{ {
Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
+97 -1
View File
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -18,6 +20,8 @@ class LessonControllerTest extends TestCase
private BookingRepository&Mockery\MockInterface $bookings; private BookingRepository&Mockery\MockInterface $bookings;
private PaymentRepository&Mockery\MockInterface $payments; private PaymentRepository&Mockery\MockInterface $payments;
private AvailabilityRepository&Mockery\MockInterface $availability; private AvailabilityRepository&Mockery\MockInterface $availability;
private OfferingRepository&Mockery\MockInterface $offerings;
private LessonDetail&Mockery\MockInterface $detail;
private LessonController $controller; private LessonController $controller;
protected function setUp(): void protected function setUp(): void
@@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase
$this->bookings = Mockery::mock(BookingRepository::class); $this->bookings = Mockery::mock(BookingRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class); $this->payments = Mockery::mock(PaymentRepository::class);
$this->availability = Mockery::mock(AvailabilityRepository::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 = []; $_POST = [];
$_GET = []; $_GET = [];
@@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase
self::assertStringNotContainsString('9:00 AM', $html); 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 private function render(): string
{ {
ob_start(); ob_start();
+95
View File
@@ -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)
);
}
}
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
@@ -32,6 +33,7 @@ class EnrollmentEndpointTest extends TestCase
Functions\when('wp_unslash')->returnArg(); Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg(); Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5); Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_time')->justReturn('2026-07-24');
$this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
@@ -108,6 +110,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 public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
{ {
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
@@ -134,4 +182,74 @@ class EnrollmentEndpointTest extends TestCase
self::assertInstanceOf(\WP_REST_Response::class, $result); self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status()); self::assertSame(201, $result->get_status());
} }
public function testWithdrawCancelsEnrolmentAndVoidsPendingWithoutCrediting(): void
{
// No withdrawal deadline set, so withdrawal is open. current_time is 2026-07-24.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
$this->enrollments->shouldReceive('updateStatus')->once()->with(3, Enrollment::STATUS_CANCELLED)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(41);
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testWithdrawRejectedAfterDeadline(): 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', withdrawalDeadline: '2026-07-10', id: 8);
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('updateStatus')->never();
$this->payments->shouldReceive('voidPending')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('withdrawal_closed', $result->get_error_code());
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
}
public function testWithdrawRejectsAnotherStudentsEnrolment(): void
{
// Enrolment belongs to student 9, but the caller is student 5.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->enrollments->shouldReceive('updateStatus')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
self::assertSame(403, $result->error_data['forbidden']['status']);
}
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
{
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(null);
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('not_found', $result->get_error_code());
self::assertSame(404, $result->error_data['not_found']['status']);
}
public function testWithdrawIsIdempotentForAlreadyCancelledEnrolment(): void
{
// Already cancelled: no status change, no deadline check, no payment void.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_CANCELLED, null, 3));
$this->offerings->shouldReceive('findById')->never();
$this->enrollments->shouldReceive('updateStatus')->never();
$this->payments->shouldReceive('voidPending')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
} }
@@ -108,6 +108,44 @@ class EnrollmentRepositoryTest extends TestCase
self::assertInstanceOf(Enrollment::class, $all[0]); 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 public function testUpdateStatusRejectsInvalid(): void
{ {
self::assertFalse($this->repo->updateStatus(1, 'bogus')); 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)) static fn (string $format, string $date) => date($format, (int) strtotime($date))
); );
Functions\when('wp_nonce_field')->justReturn(''); Functions\when('wp_nonce_field')->justReturn('');
Functions\when('current_time')->justReturn('2026-01-01');
$_GET = []; $_GET = [];
} }
@@ -183,6 +184,49 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('Invite by email', $html); 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 public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{ {
Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper')); Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
@@ -32,6 +32,8 @@ class OfferingControllerTest extends TestCase
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]); Functions\when('get_users')->justReturn([]);
// Default single-account setup: admins act as instructors.
Functions\when('get_option')->justReturn('1');
Functions\when('check_admin_referer')->justReturn(true); Functions\when('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings'); Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias( Functions\when('add_query_arg')->alias(
@@ -102,6 +104,80 @@ class OfferingControllerTest extends TestCase
self::assertStringContainsString('2 open booking slots were removed', $html); 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 testAddGroupClassStoresWithdrawalDeadline(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'withdrawal_deadline' => '2026-08-31',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '2026-08-31' === $o->withdrawalDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testBlankWithdrawalDeadlineLeavesItNull(): 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->withdrawalDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testGarbageClassTimeIsRejected(): void public function testGarbageClassTimeIsRejected(): void
{ {
$_POST = [ $_POST = [
@@ -376,6 +452,51 @@ class OfferingControllerTest extends TestCase
self::assertStringNotContainsString('Edit Offering', $html); self::assertStringNotContainsString('Edit Offering', $html);
} }
public function testInstructorPickerIncludesAdministratorsWhenTheyActAsInstructors(): void
{
// The reported bug: a solo studio owner runs the business from a WordPress
// administrator account and teaches through the dynamic capability grant,
// so they never hold the us_instructor role. The picker must still list
// them, otherwise there is no one to assign a class to.
Functions\when('get_option')->justReturn('1');
$admin = Mockery::mock(\WP_User::class);
$admin->ID = 3;
$admin->display_name = 'Studio Owner';
$queriedRoles = [];
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles, $admin): array {
$queriedRoles = $args['role__in'];
return [$admin];
});
$this->repository->shouldReceive('findAll')->andReturn([]);
$html = $this->render();
self::assertContains('us_instructor', $queriedRoles);
self::assertContains('administrator', $queriedRoles);
self::assertStringContainsString('Studio Owner', $html);
self::assertStringContainsString('<option value="3"', $html);
}
public function testInstructorPickerExcludesAdministratorsWhenGrantDisabled(): void
{
// With the "admins are instructors" toggle off, an admin is not a teacher,
// so only the explicit us_instructor role is queried.
Functions\when('get_option')->justReturn('0');
$queriedRoles = null;
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles): array {
$queriedRoles = $args['role__in'];
return [];
});
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
self::assertSame(['us_instructor'], $queriedRoles);
}
private function render(): string private function render(): string
{ {
ob_start(); ob_start();
@@ -118,4 +118,37 @@ class OfferingEndpointTest extends TestCase
self::assertArrayNotHasKey('etransfer_email', $data[0]); self::assertArrayNotHasKey('etransfer_email', $data[0]);
} }
public function testCreateRejectsTitleLongerThanColumnLimit(): void
{
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_email')->returnArg();
$this->repository->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'kind' => Offering::KIND_GROUP_CLASS,
'title' => str_repeat('a', Offering::MAX_TITLE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_offering']['status']);
}
public function testCreateRejectsScheduleNoteLongerThanColumnLimit(): void
{
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_email')->returnArg();
$this->repository->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Choir',
'schedule_note' => str_repeat('a', Offering::MAX_SCHEDULE_NOTE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_offering']['status']);
}
} }
@@ -192,6 +192,29 @@ class OfferingRepositoryTest extends TestCase
self::assertSame(1, $this->repo->insert($offering)); self::assertSame(1, $this->repo->insert($offering));
} }
public function testInsertPersistsWithdrawalDeadline(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_offerings',
Mockery::on(static fn (array $data): bool => $data['withdrawal_deadline'] === '2026-08-31'),
Mockery::type('array')
);
$this->db->insert_id = 1;
$offering = new Offering(
instructorId: 5,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
withdrawalDeadline: '2026-08-31',
);
self::assertSame(1, $this->repo->insert($offering));
}
public function testDeleteCallsWpdbDelete(): void public function testDeleteCallsWpdbDelete(): void
{ {
$this->db->shouldReceive('delete') $this->db->shouldReceive('delete')
+79
View File
@@ -276,5 +276,84 @@ class OfferingTest extends TestCase
self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS); self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS);
self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES); self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_FULL_TERM, 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']);
}
public function testIsWithdrawalOpenOnAndBeforeTheDeadlineDay(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', withdrawalDeadline: '2026-08-31');
self::assertTrue($offering->isWithdrawalOpen('2026-08-30'));
self::assertTrue($offering->isWithdrawalOpen('2026-08-31'));
self::assertFalse($offering->isWithdrawalOpen('2026-09-01'));
}
public function testIsWithdrawalOpenAlwaysTrueWithoutADeadline(): void
{
// Unlike the enrolment deadline, a withdrawal deadline has no default:
// an unset deadline leaves self-withdrawal open indefinitely.
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
self::assertTrue($offering->isWithdrawalOpen('2099-01-01'));
}
public function testToArrayIncludesWithdrawalDeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', withdrawalDeadline: '2026-08-31', id: 10);
self::assertSame('2026-08-31', $offering->toArray()['withdrawal_deadline']);
} }
} }
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class CreditRepositoryTest extends TestCase
{
private \wpdb $db;
private CreditRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(\wpdb::class);
$this->db->prefix = 'wp_';
$this->repo = new CreditRepository($this->db);
}
public function testInsertReturnsId(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_credits',
Mockery::on(static function (array $d): bool {
return $d['student_id'] === 5
&& $d['amount'] === 33.0
&& $d['remaining'] === 33.0
&& $d['source_lesson_id'] === 77
&& $d['status'] === Credit::STATUS_AVAILABLE;
}),
Mockery::type('array')
);
$this->db->insert_id = 300;
$credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77');
self::assertSame(300, $this->repo->insert($credit));
}
public function testExistsForLessonReturnsTrueWhenRowFound(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/source_lesson_id = %d/'), 'wp_us_credits', 77)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('300');
self::assertTrue($this->repo->existsForLesson(77));
}
public function testExistsForLessonReturnsFalseWhenNone(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->andReturn(null);
self::assertFalse($this->repo->existsForLesson(77));
}
public function testAvailableBalanceSumsRemaining(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SUM\( remaining \)/'), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('45.00');
self::assertSame(45.0, $this->repo->availableBalance(5));
}
public function testConsumeDrawsDownOldestFirstAndMarksSpentConsumed(): void
{
// Two available credits ($20 then $30); consuming $35 empties the first and
// takes $15 from the second, leaving it $15 and still available.
$rows = [
(object) ['id' => '1', 'student_id' => '5', 'amount' => '20.00', 'remaining' => '20.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-01 09:00:00', 'updated_at' => null],
(object) ['id' => '2', 'student_id' => '5', 'amount' => '30.00', 'remaining' => '30.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-02 09:00:00', 'updated_at' => null],
];
Functions\expect('current_time')->with('mysql')->andReturn('2026-07-15 12:00:00');
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn($rows);
// First credit fully spent -> consumed.
$this->db->shouldReceive('update')
->once()
->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 0.0 && $d['status'] === Credit::STATUS_CONSUMED), ['id' => 1], Mockery::type('array'), Mockery::type('array'));
// Second credit partly spent -> stays available with $15 remaining.
$this->db->shouldReceive('update')
->once()
->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 15.0 && $d['status'] === Credit::STATUS_AVAILABLE), ['id' => 2], Mockery::type('array'), Mockery::type('array'));
$this->repo->consume(5, 35.0);
}
public function testConsumeIgnoresNonPositiveAmount(): void
{
$this->db->shouldNotReceive('get_results');
$this->db->shouldNotReceive('update');
$this->repo->consume(5, 0.0);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?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 testCreditReducesTheTotalDue(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static function (string $body): bool {
// Line shows the full 35.00; credit line shows -20.00; total due 15.00.
return str_contains($body, '35.00')
&& str_contains($body, '-CAD 20.00')
&& str_contains($body, 'Total due: CAD 15.00');
})
)
->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, 'REF1', 20.0));
}
public function testCreditCoveringEverythingLeavesZeroDueAndNoEtransferLine(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static function (string $body): bool {
return str_contains($body, 'Total due: CAD 0.00')
&& ! 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, '', 35.0));
}
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))); 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 public function testMarkPaidUpdatesStatusAndReceipt(): void
{ {
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
+162 -1
View File
@@ -9,6 +9,8 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
@@ -26,6 +28,7 @@ class PaymentServiceTest extends TestCase
private EnrollmentRepository $enrollments; private EnrollmentRepository $enrollments;
private StudioSettings $settings; private StudioSettings $settings;
private StripeGateway $stripe; private StripeGateway $stripe;
private CreditRepository $credits;
private PaymentService $service; private PaymentService $service;
protected function setUp(): void protected function setUp(): void
@@ -39,6 +42,7 @@ class PaymentServiceTest extends TestCase
$this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class); $this->settings = Mockery::mock(StudioSettings::class);
$this->stripe = Mockery::mock(StripeGateway::class); $this->stripe = Mockery::mock(StripeGateway::class);
$this->credits = Mockery::mock(CreditRepository::class);
$this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault(); $this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault();
// Confirming a lesson looks it up to detect a weekly series; single // Confirming a lesson looks it up to detect a weekly series; single
@@ -52,7 +56,8 @@ class PaymentServiceTest extends TestCase
$this->bookings, $this->bookings,
$this->enrollments, $this->enrollments,
$this->settings, $this->settings,
$this->stripe $this->stripe,
$this->credits
); );
Functions\when('get_userdata')->justReturn(false); Functions\when('get_userdata')->justReturn(false);
@@ -76,6 +81,17 @@ class PaymentServiceTest extends TestCase
$this->service->voidPending(50); $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 public function testVoidPendingLeavesPaidPaymentAlone(): void
{ {
// Refunds are manual: cancelling a paid lesson must not touch the ledger. // Refunds are manual: cancelling a paid lesson must not touch the ledger.
@@ -329,6 +345,151 @@ class PaymentServiceTest extends TestCase
self::assertTrue($this->service->handleWebhook('{}', 'sig')); self::assertTrue($this->service->handleWebhook('{}', 'sig'));
} }
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
{
// A paid single-lesson payment: the whole total (incl. tax) is credited.
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 5
&& $c->amount === 33.00
&& $c->remaining === 33.00
&& $c->sourceLessonId === 77))
->andReturn(300);
$this->credits->shouldReceive('findById')->with(300)->andReturn(
new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300)
);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void
{
// A monthly scheduled charge covering 3 lessons: one cancellation credits a third.
$paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(301);
$this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsUnpaidPayment(): void
{
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($pending);
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsWhenNoPayment(): void
{
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsAlreadyCredited(): void
{
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true);
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonUsesSeriesSizeForUpfrontSeries(): void
{
// A non-anchor series lesson has no payment_id of its own; the anchor's
// upfront (unscheduled) payment covers the whole 4-lesson series.
$anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment);
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
$this->credits->shouldReceive('existsForLesson')->with(43)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(302);
$this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testApplyCreditsReturnsEmptyWhenNoBalance(): void
{
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(0.0);
self::assertSame([], $this->service->applyCredits(5, [$this->pending(500, 40.00)]));
}
public function testApplyCreditsPartiallyCoversWithoutMarkingPaid(): void
{
// $30 credit against a $40 charge: applied but still owing, so it stays pending.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(30.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 30.0)->andReturn(true);
$this->payments->shouldNotReceive('markPaid');
$this->credits->shouldReceive('consume')->once()->with(5, 30.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]);
self::assertSame([500 => 30.0], $applied);
}
public function testApplyCreditsFullyCoversMarksPaidByCreditAndConfirms(): void
{
// $50 credit against a $40 charge: fully covered -> settled + registration confirmed.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true);
$this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->credits->shouldReceive('consume')->once()->with(5, 40.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]);
self::assertSame([500 => 40.0], $applied);
}
public function testApplyCreditsSpreadsAcrossChargesOldestFirst(): void
{
// $50 balance across two $40 charges: first fully covered, second partly.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true);
$this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('addCreditApplied')->once()->with(501, 10.0)->andReturn(true);
$this->credits->shouldReceive('consume')->once()->with(5, 50.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00), $this->pending(501, 40.00)]);
self::assertSame([500 => 40.0, 501 => 10.0], $applied);
}
private function pending(int $id, float $amount): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
}
private function intentEvent(string $type, string $intentId): \Stripe\Event private function intentEvent(string $type, string $intentId): \Stripe\Event
{ {
$intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']); $intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']);
+22
View File
@@ -73,6 +73,28 @@ class PaymentTest extends TestCase
self::assertSame(100.00, $payment->total()); self::assertSame(100.00, $payment->total());
} }
public function testNetDueSubtractsAppliedCredit(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00, taxRate: 13.0, taxAmount: 13.00, creditApplied: 40.00);
self::assertSame(113.00, $payment->total());
self::assertSame(73.00, $payment->netDue());
}
public function testNetDueFloorsAtZeroWhenCreditExceedsTotal(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00, creditApplied: 50.00);
self::assertSame(0.0, $payment->netDue());
}
public function testNetDueEqualsTotalWithoutCredit(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00);
self::assertSame(30.00, $payment->netDue());
}
public function testToSummaryArrayContainsOnlyClientFacingFields(): void public function testToSummaryArrayContainsOnlyClientFacingFields(): void
{ {
$summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray(); $summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray();
@@ -0,0 +1,309 @@
<?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();
// No account credit unless a test says otherwise.
$this->payments->shouldReceive('applyCredits')->andReturn([])->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'), 0.0);
$this->runner->run();
}
public function testAppliesAccountCreditToTheRun(): 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) ]);
$payment = $this->pending(500, '2026-07-14');
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment);
// Student holds $20 credit, applied to the one $35 charge — still $15 owing,
// so the payment stays in the notice batch and the notice quotes the credit.
$this->payments->shouldReceive('applyCredits')
->once()
->with(5, Mockery::on(static fn (array $p): bool => count($p) === 1))
->andReturn([500 => 20.0]);
$this->payments->shouldReceive('assignNoticeBatch')
->once()
->with([500], Mockery::type('string'));
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::type('array'), Mockery::type('string'), 20.0);
$this->runner->run();
}
public function testCreditFullyCoveringAChargeLeavesItOutOfTheBatch(): 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) ]);
$payment = $this->pending(500, '2026-07-14');
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment);
// Credit covers the whole $35 charge: nothing owing, so no reconciliation
// batch and no reference on the (zero-balance) notice.
$this->payments->shouldReceive('applyCredits')->once()->andReturn([500 => 35.0]);
$this->payments->shouldReceive('assignNoticeBatch')->once()->with([], '');
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::type('array'), '', 35.0);
$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,
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Policy;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyEndpoint;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PolicyEndpointTest extends TestCase
{
private PolicyRepository&Mockery\MockInterface $policies;
private PolicyService&Mockery\MockInterface $service;
private PolicyEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_title')->returnArg();
$this->policies = Mockery::mock(PolicyRepository::class);
$this->service = Mockery::mock(PolicyService::class);
$this->endpoint = new PolicyEndpoint(
$this->policies,
Mockery::mock(PolicyVersionRepository::class),
$this->service,
);
}
public function testCreateRejectsTitleLongerThanColumnLimit(): void
{
$this->service->shouldNotReceive('createPolicy');
$request = new \WP_REST_Request([
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_policy']['status']);
}
public function testCreateRejectsSlugLongerThanColumnLimit(): void
{
$this->service->shouldNotReceive('createPolicy');
$request = new \WP_REST_Request([
'title' => 'Cancellation',
'slug' => str_repeat('a', Policy::MAX_SLUG_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_policy']['status']);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Registration;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionEndpoint;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class QuestionEndpointTest extends TestCase
{
private QuestionRepository&Mockery\MockInterface $questions;
private OfferingRepository&Mockery\MockInterface $offerings;
private QuestionEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_user_can')->justReturn(false);
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('sanitize_text_field')->returnArg();
$this->questions = Mockery::mock(QuestionRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->endpoint = new QuestionEndpoint($this->questions, $this->offerings);
// The caller (instructor 5) owns offering 9, so the ownership gate passes
// and validation is reached.
$this->offerings->shouldReceive('findById')->with(9)->andReturn(
new Offering(instructorId: 5, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 9)
);
}
public function testCreateRejectsLabelLongerThanColumnLimit(): void
{
// The insert must never be attempted for an over-long label — the bug was
// that it reached the DB, silently failed, and returned success anyway.
$this->questions->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'offering_id' => 9,
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_question']['status']);
}
public function testCreateAcceptsLabelAtColumnLimit(): void
{
$this->questions->shouldReceive('insert')->once()->andReturn(42);
$request = new \WP_REST_Request([
'offering_id' => 9,
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_REST_Response::class, $response);
self::assertSame(201, $response->get_status());
}
}
@@ -212,4 +212,28 @@ class QuestionRepositoryTest extends TestCase
self::assertTrue($this->repo->delete(4)); self::assertTrue($this->repo->delete(4));
} }
public function testEnsureOfferingNullableRunsAlterAndReportsSuccess(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/ALTER TABLE %i MODIFY offering_id .*NULL/'), 'wp_us_questions')
->andReturn('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL');
$this->db->shouldReceive('query')
->once()
->with('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL')
->andReturn(0);
// A successful DDL query returns 0 rows affected (not false).
self::assertTrue($this->repo->ensureOfferingNullable());
}
public function testEnsureOfferingNullableReportsFailureWhenQueryFails(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('ALTER ...');
$this->db->shouldReceive('query')->once()->andReturn(false);
self::assertFalse($this->repo->ensureOfferingNullable());
}
} }
+3 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.1.1 * Version: 1.2.1
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.1.1'); define('USC_VERSION', '1.2.1');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__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 { register_deactivation_hook(__FILE__, static function (): void {
wp_clear_scheduled_hook('us_generate_due_payments');
flush_rewrite_rules(); flush_rewrite_rules();
}); });