Compare commits
45
Commits
v1.0.0
...
b0952ca06d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0952ca06d
|
||
|
|
30928addf8 | ||
|
|
27793fa0aa | ||
|
|
c611268bdb | ||
|
|
721c4be1d6
|
||
|
|
3aa65bad06 | ||
|
|
f3ba09b195 | ||
|
|
771942be8b | ||
|
|
242150569b
|
||
|
|
fae1fd08ba | ||
|
|
2c4b481077
|
||
|
|
f552c3952a | ||
|
|
e8e66eef3c
|
||
|
|
cf296329a0 | ||
|
|
3f9aef7746
|
||
|
|
d1dd30dc60 | ||
|
|
4328e8fb5f
|
||
|
|
36e7178158 | ||
|
|
32619a1b75
|
||
|
|
1a447743b3 | ||
|
|
fc7c0fa966
|
||
|
|
bf29162587
|
||
|
|
991ed2f5ad | ||
|
|
ad2ddefebf | ||
|
|
1fe28d5575 | ||
|
|
51dd032668
|
||
|
|
37ec8a3315 | ||
|
|
f93c5aba05 | ||
|
|
013558019e
|
||
|
|
87cfe921a9
|
||
|
|
00376799b9
|
||
|
|
b066bef353
|
||
|
|
bba24566a6
|
||
|
|
4bca2fbc96
|
||
|
|
06c8d42cc0
|
||
|
|
a281935811
|
||
|
|
25aeba9dc1
|
||
|
|
5f9d5ffc4f
|
||
|
|
90fdde8c06
|
||
|
|
169f7b6a13
|
||
|
|
8b90b8d78d
|
||
|
|
a777f5d05a
|
||
|
|
83be388186
|
||
|
|
8d6615da67
|
||
|
|
49c59a950c
|
@@ -46,6 +46,22 @@ jobs:
|
||||
- name: Build plugin zip
|
||||
run: composer build
|
||||
|
||||
# Pull the section for this version out of CHANGELOG.md so it can become
|
||||
# the release body. Matches "## [x.y.z]" and prints every line up to the
|
||||
# next "## " heading. A missing section is a warning, not a failure — the
|
||||
# release still publishes with empty notes.
|
||||
- name: Extract changelog notes
|
||||
run: |
|
||||
version="${{ steps.meta.outputs.version }}"
|
||||
awk -v ver="$version" '
|
||||
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac > release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::warning::No CHANGELOG.md section found for version ${version}"
|
||||
fi
|
||||
|
||||
- name: Publish release with zip asset
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -65,11 +81,19 @@ jobs:
|
||||
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
body="$(jq -Rs --arg tag "${GITHUB_REF_NAME}" --argjson pre "${prerelease}" \
|
||||
'{tag_name:$tag, name:$tag, prerelease:$pre, body:.}' release-notes.md)"
|
||||
release_id="$(curl -fsS -X POST "${api}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \
|
||||
| jq -r '.id')"
|
||||
-d "${body}" | jq -r '.id')"
|
||||
else
|
||||
# Release pre-created via the UI: fill in the notes from the changelog.
|
||||
body="$(jq -Rs '{body:.}' release-notes.md)"
|
||||
curl -fsS -X PATCH "${api}/releases/${release_id}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "${body}" > /dev/null
|
||||
fi
|
||||
|
||||
echo "Attaching ${zip} to release ${release_id}"
|
||||
@@ -77,3 +101,65 @@ jobs:
|
||||
"${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${zip}" > /dev/null
|
||||
|
||||
# After a stable release, move main forward: bump the plugin to the next patch
|
||||
# version and open a fresh changelog section for it, via a PR. Skipped for
|
||||
# pre-releases (tags containing a hyphen, e.g. v1.2.0-rc.1) — those don't
|
||||
# advance the mainline version.
|
||||
bump-version:
|
||||
name: Open next-version bump PR
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute next patch version
|
||||
id: next
|
||||
run: |
|
||||
current="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
|
||||
base="${current%%-*}" # drop any pre-release suffix
|
||||
major="${base%%.*}"
|
||||
rest="${base#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest#*.}"
|
||||
next="${major}.${minor}.$((patch + 1))"
|
||||
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply version bump and open changelog section
|
||||
run: |
|
||||
next="${{ steps.next.outputs.next }}"
|
||||
# Plugin header + USC_VERSION constant must stay in lockstep.
|
||||
sed -i -E "s/^([[:space:]]*\*?[[:space:]]*Version:[[:space:]]*).*/\1${next}/" unsupervised-schedular.php
|
||||
sed -i -E "s/(define\('USC_VERSION', ')[^']+('\);)/\1${next}\2/" unsupervised-schedular.php
|
||||
# Insert an empty section for the new version above the current top one
|
||||
# (the first "## [" heading in the file).
|
||||
awk -v ver="$next" '
|
||||
!done && /^## \[/ { print "## [" ver "]"; print ""; done = 1 }
|
||||
{ print }
|
||||
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
|
||||
- name: Open pull request
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
next="${{ steps.next.outputs.next }}"
|
||||
branch="release/bump-${next}"
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
git checkout -b "${branch}"
|
||||
git commit -am "Bump version to ${next} and open changelog section"
|
||||
git push origin "${branch}"
|
||||
|
||||
curl -fsS -X POST "${api}/pulls" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg head "${branch}" --arg title "Bump version to ${next}" \
|
||||
'{head:$head, base:"main", title:$title, body:"Automated post-release bump to the next patch version. Record changes for this version under its changelog heading."}')" \
|
||||
> /dev/null
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Unsupervised Scheduler are documented in this file, one
|
||||
section per version, newest first. The top section is always the version the code
|
||||
on `main` currently carries (the `Version:` header in `unsupervised-schedular.php`).
|
||||
Whether a version is "released" is purely a matter of whether its tag exists — the
|
||||
changelog itself does not track that.
|
||||
|
||||
When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
`## [x.y.z]` section verbatim as the Gitea release notes, then opens a PR that bumps
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
### Added
|
||||
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
|
||||
|
||||
## [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]
|
||||
|
||||
### Fixed
|
||||
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Added
|
||||
- Invite-only group classes, so a class can be restricted to students who hold an invite.
|
||||
- Instructor group-class roster view under **My Lessons**.
|
||||
- Cancellation cutoff that limits how close to a lesson a student can cancel.
|
||||
- Studio-defined account-registration questions collected during student sign-up.
|
||||
- Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes.
|
||||
- Students see who teaches each group class and when it meets on the enrolment page. Instructor names in the group-class views (front and back end) show the instructor's real name (first + last) or nickname, never their login/username.
|
||||
|
||||
### Changed
|
||||
- Plugin metadata links now point at Unsupervised and the Gitea repository.
|
||||
- The instructor **My Group Classes** view is now a summary of classes with enrolment counts; each class links through to a per-class **details page** (class schedule, roster, and — for invite-only classes — the controls to add or invite students), rather than listing every student inline. Managing who is in an invite-only class is now done from that details page. The studio-admin **Group Classes** page is likewise a per-class summary that links through to the same details page, so a studio admin — including an owner-operator who also teaches — can view any class's roster and manage its invite-only membership.
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
First stable release: instructor/student lesson scheduling for WordPress, including
|
||||
availability management, one-on-one and group-class booking, Stripe payments, student
|
||||
self-registration with email confirmation and admin approval, group invite links, and
|
||||
self-update from tagged Gitea releases.
|
||||
@@ -46,6 +46,26 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.us-my-lesson-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.us-my-lesson-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.us-my-lesson-duration {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.us-my-lesson-when {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.us-my-lesson-actions {
|
||||
@@ -54,6 +74,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-show-all-lessons {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-show-all-lessons:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.us-cancel-lesson {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
+17
-4
@@ -5,7 +5,7 @@
|
||||
const { registerBlockType } = wp.blocks;
|
||||
const { createElement: el, useState, useEffect } = wp.element;
|
||||
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
||||
const { PanelBody, SelectControl, ToggleControl } = wp.components;
|
||||
const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
|
||||
const { useSelect } = wp.data;
|
||||
const apiFetch = wp.apiFetch;
|
||||
const ServerSideRender = wp.serverSideRender;
|
||||
@@ -151,10 +151,12 @@
|
||||
shortcode: 'us_student_register',
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
inviteOnlyMessage: { type: 'string', default: '' },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
inspector: (attributes, setAttributes) => [
|
||||
el(
|
||||
PanelBody,
|
||||
{ title: __('After email confirmation', 'unsupervised-schedular') },
|
||||
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
|
||||
el(PageSelect, {
|
||||
label: __('Sign-in page', 'unsupervised-schedular'),
|
||||
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
|
||||
@@ -163,6 +165,17 @@
|
||||
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',
|
||||
@@ -179,7 +192,7 @@
|
||||
{ title: __('Classes shown', 'unsupervised-schedular') },
|
||||
el(GroupClassSelect, {
|
||||
label: __('Class', 'unsupervised-schedular'),
|
||||
help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'),
|
||||
help: __('Show only one group class, for embedding on a page dedicated to it. That class’s description is left out — the card shows just the schedule, price and enrolment controls.', 'unsupervised-schedular'),
|
||||
value: attributes.offeringId,
|
||||
onChange: (offeringId) => setAttributes({ offeringId }),
|
||||
})
|
||||
|
||||
+38
-9
@@ -388,6 +388,25 @@
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// How many upcoming lessons to show before the "Show all" reveal.
|
||||
const INITIAL_LESSON_COUNT = 5;
|
||||
|
||||
function lessonRowHtml(l) {
|
||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
||||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||
return `
|
||||
<div class="us-my-lesson">
|
||||
<span class="us-my-lesson-info">
|
||||
<strong class="us-my-lesson-title">${title}${duration}</strong>
|
||||
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMyLessons(lessons) {
|
||||
const upcoming = lessons.filter((l) => l.start_dt);
|
||||
if (!upcoming.length) {
|
||||
@@ -395,20 +414,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Show only the soonest few by default; the rest sit hidden behind a
|
||||
// reveal so a busy student's list stays short.
|
||||
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
|
||||
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
|
||||
|
||||
myLessons.innerHTML = `
|
||||
<div class="us-my-lessons">
|
||||
<h3>Your upcoming lessons</h3>
|
||||
${upcoming.map((l) => `
|
||||
<div class="us-my-lesson">
|
||||
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${visible.map(lessonRowHtml).join('')}
|
||||
${hidden.length ? `
|
||||
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
|
||||
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
|
||||
` : ''}
|
||||
</div>`;
|
||||
|
||||
const moreBox = myLessons.querySelector('.us-my-lessons-more');
|
||||
const showAll = myLessons.querySelector('.us-show-all-lessons');
|
||||
if (showAll && moreBox) {
|
||||
showAll.addEventListener('click', () => {
|
||||
moreBox.hidden = false;
|
||||
showAll.remove();
|
||||
});
|
||||
}
|
||||
|
||||
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
|
||||
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
// When the shortcode/block pins a single offering, only that class is
|
||||
// shown, so the page can be embedded alongside a full class description.
|
||||
// The class's own description is then omitted from the card — the page it
|
||||
// sits on already describes the class — leaving the schedule, price and
|
||||
// enrolment controls.
|
||||
const singleOfferingId = Number(app.dataset.offering || 0);
|
||||
|
||||
function apiFetch(path, options = {}) {
|
||||
@@ -89,7 +92,48 @@
|
||||
return `${formatDate(o.term_start)} – ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledOfferingIds) {
|
||||
// Format a stored H:i(:s) class time as a friendly local-clock label.
|
||||
function timeLabel(o) {
|
||||
if (!o.class_time) return '';
|
||||
const [h, m] = o.class_time.split(':').map(Number);
|
||||
const d = new Date();
|
||||
d.setHours(h, m, 0, 0);
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// The "when" line combines the date (or date range) with the class time.
|
||||
function whenLabel(o) {
|
||||
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
|
||||
// comparison against the class's Y-m-d enrolment deadline.
|
||||
function todayYmd() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The effective enrolment deadline: the instructor's set deadline, or the
|
||||
// first class day by default. Empty when the class has no dates at all.
|
||||
function enrolmentDeadline(o) {
|
||||
return o.enrollment_deadline || o.term_start || '';
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day. Mirrors the server-side
|
||||
// Offering::isEnrollmentOpen() gate.
|
||||
function isEnrollmentOpen(o) {
|
||||
const deadline = enrolmentDeadline(o);
|
||||
return !deadline || todayYmd() <= deadline;
|
||||
}
|
||||
|
||||
// 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');
|
||||
if (singleOfferingId) {
|
||||
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
|
||||
@@ -104,13 +148,22 @@
|
||||
list.innerHTML = groups.map((o) => `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</p>` : ''}
|
||||
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
|
||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||
${enrolledOfferingIds.has(Number(o.id))
|
||||
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
|
||||
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
|
||||
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${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>
|
||||
`).join('');
|
||||
|
||||
@@ -118,6 +171,20 @@
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
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) {
|
||||
@@ -200,9 +267,9 @@
|
||||
])
|
||||
.then(([offerings, enrollments]) => renderClasses(
|
||||
offerings,
|
||||
new Set(enrollments
|
||||
new Map(enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.map((e) => Number(e.offering_id)))
|
||||
.map((e) => [Number(e.offering_id), e.id]))
|
||||
))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Progressive enhancement for the two-step student registration form.
|
||||
*
|
||||
* When account-signup questions are configured the form renders two panels
|
||||
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
|
||||
* single form marked `data-steps="1"`. This script hides step two behind a
|
||||
* "Next" button that only advances once step one passes native validation.
|
||||
* Without JS both panels stay visible and the single submit still works.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function enhance(form) {
|
||||
var step1 = form.querySelector('[data-step="1"]');
|
||||
var step2 = form.querySelector('[data-step="2"]');
|
||||
var next = form.querySelector('.us-reg-next');
|
||||
var back = form.querySelector('.us-reg-back');
|
||||
|
||||
if (!step1 || !step2 || !next) {
|
||||
return;
|
||||
}
|
||||
|
||||
function show(step) {
|
||||
step1.hidden = step !== 1;
|
||||
step2.hidden = step !== 2;
|
||||
}
|
||||
|
||||
show(1);
|
||||
|
||||
next.addEventListener('click', function () {
|
||||
var fields = step1.querySelectorAll('input, select, textarea');
|
||||
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
if (!fields[i].checkValidity()) {
|
||||
fields[i].reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
show(2);
|
||||
});
|
||||
|
||||
if (back) {
|
||||
back.addEventListener('click', function () {
|
||||
show(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
|
||||
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
enhance(forms[i]);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -69,6 +69,7 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
|
||||
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
|
||||
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
|
||||
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
|
||||
| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise |
|
||||
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
|
||||
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
|
||||
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
|
||||
@@ -76,6 +77,15 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
|
||||
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
|
||||
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
|
||||
|
||||
## Registration Questions (signup step two)
|
||||
When the studio has configured **account-scope** registration questions
|
||||
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
|
||||
registration form becomes two steps: name/email/password/policies first, then the required
|
||||
questions. This applies to **every** signup path (invite, group link, self-approval).
|
||||
Required answers are validated before the account is created, and are stored against the new
|
||||
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them
|
||||
under **Registration Information** on the student's admin screen.
|
||||
|
||||
## Policy Acceptance Scope
|
||||
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
|
||||
`signup`, `booking`, or `both` (see `policies.md`). The registration form requires
|
||||
@@ -87,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.
|
||||
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.
|
||||
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.
|
||||
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)
|
||||
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
|
||||
@@ -116,6 +126,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
||||
|
||||
## 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`.
|
||||
- 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
|
||||
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Feature: Cancellation Cutoff
|
||||
|
||||
## Overview
|
||||
Students may cancel their own lessons online — but not indefinitely close to the
|
||||
start time. A **cancellation cutoff** closes student-initiated cancellation once
|
||||
a lesson begins within a configured window. Instructors and studio admins are
|
||||
never subject to the cutoff: they can cancel a lesson at any time through the
|
||||
lesson-status and student-management flows.
|
||||
|
||||
The window is resolved per lesson:
|
||||
|
||||
1. If the lesson's **offering** sets its own cutoff, that value is used.
|
||||
2. Otherwise the **studio default** applies.
|
||||
|
||||
Both values are expressed and computed in **hours**. The studio default is
|
||||
entered and displayed to the admin in **days** for convenience; a per-offering
|
||||
override is entered directly in hours (a finer-grained "time").
|
||||
|
||||
## Data Model
|
||||
|
||||
### Option `us_cancellation_cutoff_hours`
|
||||
Studio-wide default cutoff, stored as an integer number of **hours**. Defaults to
|
||||
`24` (one day) when unset. `0` means students may cancel at any time.
|
||||
|
||||
### Column `{prefix}us_offerings.cancellation_cutoff_hours`
|
||||
Nullable `SMALLINT UNSIGNED`. `NULL` means "inherit the studio default"; any set
|
||||
value (including `0` — cancel any time) overrides it for that offering.
|
||||
|
||||
## Resolution & Enforcement
|
||||
`Booking\CancellationPolicy` owns the logic:
|
||||
|
||||
- `cutoffHours(?int $offeringCutoffHours): int` — the offering's override when it
|
||||
is a non-negative value, otherwise the studio default.
|
||||
- `studentMayCancel(string $slotStartDt, ?int $offeringCutoffHours, ?string $now): bool`
|
||||
— false once `now` is within the effective cutoff of the slot start. A zero
|
||||
cutoff always allows cancellation; unparseable datetimes fail open so a student
|
||||
is never trapped by bad data. Comparisons use WordPress-local time
|
||||
(`current_time('mysql')`), matching how upcoming lessons are computed.
|
||||
- `describeCutoff(int $hours): string` — humanises a cutoff for messages
|
||||
(whole days as days, otherwise hours).
|
||||
|
||||
`Booking\BookingEndpoint::cancel()` (the student endpoint,
|
||||
`POST /bookings/{id}/cancel`) consults the policy before cancelling and returns a
|
||||
`cancellation_closed` (HTTP 403) error explaining the window when it is too late.
|
||||
The instructor status endpoint (`PATCH /bookings/{id}/status`) and
|
||||
`Auth\StudentActions::cancelLesson()` (studio-admin student view) bypass the
|
||||
policy entirely.
|
||||
|
||||
## Admin Interface
|
||||
- **Studio Settings → Cancellations**: "Cancellation cutoff (days)" — the studio
|
||||
default, entered/displayed in days, stored in hours.
|
||||
- **Offerings** add/edit form: "Cancellation cutoff (hours)" — an optional
|
||||
per-offering override; blank inherits the studio default, `0` allows anytime
|
||||
cancellation.
|
||||
|
||||
## Implementation
|
||||
- Service: `Unsupervised\Schedular\Booking\CancellationPolicy`
|
||||
- Studio default: `Unsupervised\Schedular\Payment\StudioSettings::cancellationCutoffHours()`
|
||||
(option `us_cancellation_cutoff_hours`)
|
||||
- Per-offering value: `Unsupervised\Schedular\Offering\Offering::$cancellationCutoffHours`
|
||||
- Enforcement: `Unsupervised\Schedular\Booking\BookingEndpoint::cancel()`
|
||||
- Wiring: `RestRegistrar` constructs `new CancellationPolicy( new StudioSettings() )`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/CancellationPolicyTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` (cutoff cases in `cancel()`)
|
||||
- `tests/Unit/Payment/StudioSettingsTest.php` (default getter)
|
||||
- `tests/Unit/Offering/OfferingTest.php`, `tests/Unit/Offering/OfferingRepositoryTest.php`
|
||||
(new column round-trips)
|
||||
@@ -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`)
|
||||
@@ -30,7 +30,7 @@ Four blocks have sidebar (inspector) options:
|
||||
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
|
||||
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
||||
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
||||
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
||||
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
||||
|
||||
The page selects list all published pages; if a chosen page is later deleted,
|
||||
the blocks fall back to their defaults. The group-classes block's class
|
||||
@@ -78,7 +78,9 @@ placeholder content:
|
||||
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
|
||||
disabled Book buttons.
|
||||
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a
|
||||
disabled Enrol button.
|
||||
disabled Enrol button. When `offeringId` pins a single class the preview
|
||||
drops the sample description, matching what the live page renders in that
|
||||
mode.
|
||||
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
||||
no request-state dependencies).
|
||||
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
||||
|
||||
+130
-14
@@ -3,6 +3,8 @@
|
||||
## Overview
|
||||
Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment).
|
||||
|
||||
A group class can be marked **invite-only** (`us_offerings.access_mode = invite_only`, see `offerings.md`). Invite-only classes are hidden from the public catalog — they never appear in the student booking/group-class list — and can only be enrolled in by students the instructor has let in. See **Invite-only access** below.
|
||||
|
||||
## Data Model — `{prefix}us_group_enrollments`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -15,11 +17,19 @@ Students enrol in a group class — an offering of kind `group_class` — as a c
|
||||
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||
| `enrolled_at` | DATETIME | Insertion time |
|
||||
|
||||
## Class Dates
|
||||
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
|
||||
one-off classes end the day they start; weekly classes run a set number of
|
||||
sessions. The class card on the enrolment page shows the date or date range
|
||||
with the session count.
|
||||
## Class Dates, Time, and Instructor
|
||||
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
|
||||
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
|
||||
start; weekly classes run a set number of sessions, all at `class_time`. The class
|
||||
card on the enrolment page shows **when** the class meets (the date or date range
|
||||
plus the start time) and **who** teaches it (the assigned instructor's name,
|
||||
surfaced as `instructor_name` on the `GET /offerings` response). Instructor names
|
||||
in the group-class views (front and back end) use the instructor's real name
|
||||
(first + last) or nickname, never their login — see `Auth\UserName::format()`.
|
||||
|
||||
Assigning an instructor to a scheduled class removes that instructor's open
|
||||
booking slots at the class time and flags any already-booked lesson that clashes;
|
||||
see **Instructor assignment** in `offerings.md`.
|
||||
|
||||
## Enrolment Flow
|
||||
The class list is loaded together with the student's own enrolments
|
||||
@@ -38,11 +48,48 @@ cancelled enrolment does not block re-enrolling).
|
||||
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||
a class at capacity rejects further enrolments.
|
||||
|
||||
Enrolment also closes after the class's **enrolment deadline** (the instructor's
|
||||
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
|
||||
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
|
||||
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
|
||||
of the Enrol button. While enrolment is still open the class card shows an
|
||||
"Enrol by" line with the effective deadline date.
|
||||
|
||||
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
|
||||
can still enrol someone by hand from the class **details page** — the **Add students
|
||||
directly** control, available for every group class, deliberately bypasses the
|
||||
deadline (and capacity) so a **late enrolment** can be added after the class has
|
||||
closed. Past the deadline the details page labels these as late enrolments. See
|
||||
**Admin Interface** below.
|
||||
|
||||
## 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
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|----------------------------------------------|----------------------------------|
|
||||
|----------|-------------------------------------------------|----------------------------------|
|
||||
| `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/{id}/withdraw` | Owner (the enrolled student) |
|
||||
|
||||
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
|
||||
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
|
||||
@@ -53,27 +100,96 @@ payment step).
|
||||
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the
|
||||
instructor's group classes if the caller has `view_own_lessons` on those offerings.
|
||||
|
||||
`GET /offerings` (the catalog that feeds the group-class list) returns public
|
||||
offerings **plus** any invite-only offerings the caller has an access grant for, so a
|
||||
granted student sees the private class alongside public ones. Ungranted students never
|
||||
receive it. Enrolling in an invite-only class requires a grant: `POST /enrollments`
|
||||
rejects an ungranted student with `403 invite_required`, and a successful enrolment
|
||||
flips their grant from `invited` to `enrolled`.
|
||||
|
||||
## Invite-only access
|
||||
|
||||
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
|
||||
person, separate from the enrolment itself. The instructor manages access from
|
||||
**My Lessons → My Group Classes**. **Add students directly** is available on every
|
||||
class's details page (see **Admin Interface**); invite-only classes add two more
|
||||
controls beneath it:
|
||||
|
||||
1. **Add students directly** — the selected registered students are enrolled immediately
|
||||
(`status = active`) with a **pending payment** at the class price (comp students are
|
||||
settled at once by `PaymentService`). No access grant is needed — this writes straight
|
||||
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
|
||||
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
||||
2. **Make available** — the selected registered students get an `invited` grant so the
|
||||
class appears in their own group-class list; they then self-enrol through the normal
|
||||
paid flow. Each is emailed a "you've been added" notice.
|
||||
3. **Invite by email** — for an address with no account yet: a tokenised personal invite
|
||||
(`us_invites`, carrying `offering_id`) is created and the registration link emailed,
|
||||
alongside an `invited` grant keyed by `email` + `invite_id`. If the address already has
|
||||
a **pending** invite, the grant is attached to that invite and **no second link is
|
||||
sent**. An address that already has an account is treated as **Make available** instead.
|
||||
|
||||
When an email-invited person completes registration, `RegistrationPage` links their new
|
||||
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
|
||||
class becomes enrollable for them — they choose whether to enrol.
|
||||
|
||||
### Data Model — `{prefix}us_group_access`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---------------|-----------------|-----------------------------------------------------------------------|
|
||||
| `id` | BIGINT UNSIGNED | Primary key |
|
||||
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (an invite-only group class) |
|
||||
| `student_id` | BIGINT UNSIGNED | WordPress user ID; NULL until an email invitee registers |
|
||||
| `email` | VARCHAR(191) | Email-invite grants only; used to link the account once it registers |
|
||||
| `invite_id` | BIGINT UNSIGNED | FK → `us_invites.id` for email-invite grants; NULL otherwise |
|
||||
| `status` | VARCHAR(20) | `invited` / `enrolled` / `revoked` |
|
||||
| `invited_by` | BIGINT UNSIGNED | Instructor who granted access |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Admin Interface
|
||||
- **Group Classes** (`manage_options` / studio admin): all enrolments across instructors
|
||||
- Instructors see enrolments for their own group classes under **My Lessons**
|
||||
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
|
||||
instructors — each class with its instructor, when it meets, and its active-enrolment
|
||||
count against capacity (not a flat list of individual student enrolments). Selecting a
|
||||
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
|
||||
studio admin — including an owner-operator who also teaches, for whom the instructor
|
||||
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
|
||||
membership from here. Invite actions are permitted for the class's own instructor or any
|
||||
`view_all_lessons` studio admin.
|
||||
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
|
||||
instructor's own group classes — each with when it meets and its active-enrolment count
|
||||
against capacity, plus a **View details** link (**View & invite** for invite-only
|
||||
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
|
||||
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
|
||||
price, schedule note, enrolment deadline, status), the roster of enrolled students with
|
||||
enrolment and payment status, and an **Add students** section. Every class — public or
|
||||
invite-only — carries the **Add students directly** control there, which enrols the
|
||||
selected students immediately (a late enrolment past the deadline; the section says so
|
||||
when the deadline has passed). Invite-only classes additionally get the
|
||||
**make-available** and **invite-by-email** controls plus the list of who has been invited
|
||||
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
|
||||
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
(`templates/admin/my-group-class-detail.php`) are separate templates.
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
|
||||
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
|
||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
|
||||
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute)
|
||||
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
|
||||
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
||||
|
||||
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
|
||||
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced
|
||||
> enrolments return `payment: null` and skip the payment step. See `payments.md`
|
||||
> for the card/e-transfer/comp flows. Instructor-specific enrolment views (the
|
||||
> spec's "under My Lessons") are a follow-up (#71) — this iteration ships the
|
||||
> studio-admin **Group Classes** page (`view_all_lessons`) plus
|
||||
> per-student/per-instructor REST queries.
|
||||
> for the card/e-transfer/comp flows.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions)
|
||||
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||
- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (invite-only gating)
|
||||
- `tests/Unit/GroupClass/GroupAccessTest.php`
|
||||
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
|
||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
|
||||
|
||||
@@ -29,7 +29,7 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
@@ -85,7 +85,12 @@ kind `group_class`; see `group-classes.md`.
|
||||
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
|
||||
query params, same pattern as the availability page, bucketed via
|
||||
`Availability\WeekCalendar`), with the original table available as the **List**
|
||||
view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
view — the list is where the per-lesson HST and e-transfer edit forms live. Both
|
||||
views show the booked offering's name, and each lesson links through (`?lesson_id=`)
|
||||
to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the
|
||||
offering, time, status, notes, the policy versions the student accepted (with
|
||||
acceptance time and IP), and their intake-question answers. On **My Lessons** an
|
||||
instructor may only open their own lessons; the studio **Scheduler** may open any.
|
||||
|
||||
## Frontend Shortcodes
|
||||
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
|
||||
@@ -96,6 +101,7 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
- Model: `Unsupervised\Schedular\Booking\Lesson`
|
||||
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
|
||||
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
|
||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
|
||||
@@ -109,3 +115,6 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
## Tests
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
- `tests/Unit/Booking/LessonTest.php`
|
||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||
- `tests/Unit/Booking/LessonDetailTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
@@ -15,18 +15,29 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
| `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
|
||||
| `price` | DECIMAL(10,2) | Price in dollars |
|
||||
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
|
||||
| `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) |
|
||||
| `billing_mode` | VARCHAR(20) | `one_time`, `full_term`, `weekly`, or `monthly` (see Billing Mode below) |
|
||||
| `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term |
|
||||
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
|
||||
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
|
||||
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
|
||||
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
|
||||
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
|
||||
| `withdrawal_deadline` | DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
|
||||
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
|
||||
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
|
||||
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
|
||||
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Billing Mode
|
||||
- `one_time` — charged once at booking (a single private lesson).
|
||||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||||
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
|
||||
|
||||
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||
daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`.
|
||||
|
||||
## Term Dates
|
||||
Group classes carry a term: `term_start` is the date of the first class and
|
||||
@@ -38,6 +49,52 @@ sessions** (`term_end = term_start + (N−1) weeks`, computed by
|
||||
student-facing class card shows the date (one-off) or the date range with the
|
||||
weekly session count.
|
||||
|
||||
## Class Time and Sessions
|
||||
A group class also carries `class_time` — the time of day each session starts —
|
||||
validated by `Offering::normalizeTime()` (strict `H:i`/`H:i:s`; garbage leaves it
|
||||
NULL). `class_time` + `term_start`/`term_end` + `duration_minutes` together define
|
||||
the concrete session windows: `Offering::sessionWindows()` returns one
|
||||
`{start, end}` per session (weekly across the term, or a single window for a
|
||||
one-off), and returns an empty list unless date, time, and a positive duration are
|
||||
all set. These windows drive availability reconciliation (see **Instructor
|
||||
assignment** below and `group-classes.md`).
|
||||
|
||||
## Enrolment deadline
|
||||
A group class carries an optional `enrollment_deadline` the instructor sets on the
|
||||
offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()`
|
||||
resolves it to the stored date, or to `term_start` (the first class day) when unset,
|
||||
so a class with no explicit deadline still closes to new enrolments once the first
|
||||
class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today"
|
||||
against that effective deadline (inclusive — the deadline day is still open). The
|
||||
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
|
||||
group-class list mirrors the same rule; see `group-classes.md`.
|
||||
|
||||
## Withdrawal deadline
|
||||
A group class also carries an optional `withdrawal_deadline` — the last day a
|
||||
student may withdraw *themselves* from the class. Unlike the enrolment deadline it
|
||||
has **no implicit default**: `Offering::isWithdrawalOpen($today)` treats an unset
|
||||
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
|
||||
instructor sets a date and it passes (comparison is inclusive — the deadline day is
|
||||
still open). A withdrawal made while open frees the seat and voids any still-pending
|
||||
payment but **never issues an account credit** (credits are reserved for cancelled
|
||||
lessons; see `credits.md`). Once the deadline passes the student must contact the
|
||||
studio, and an admin withdraws them by hand from the student detail page — the admin
|
||||
path is never subject to the deadline. The student endpoint enforces it
|
||||
(`403 withdrawal_closed`) and the front-end group-class list mirrors the rule; see
|
||||
`group-classes.md`.
|
||||
|
||||
## Instructor assignment
|
||||
Every offering has an owning `instructor_id`. A studio admin
|
||||
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
|
||||
assign a group class to any instructor; a plain instructor never sees the picker
|
||||
and always owns the classes they create (the posted value is ignored for them, and
|
||||
updates never reassign the owner otherwise). When a group class is saved with an
|
||||
assigned instructor and a full schedule, `Offering\ClassSlotReconciler` clears that
|
||||
instructor's **open** availability slots overlapping each session so students can't
|
||||
book them, and reports any **already-booked** lesson that clashes as a conflict for
|
||||
the studio to resolve by hand (a booked lesson is never deleted). The result is
|
||||
surfaced as an admin notice after saving.
|
||||
|
||||
## Admin Interface
|
||||
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||
- Studio admin (`manage_offerings`) manages offerings for any instructor.
|
||||
@@ -59,11 +116,14 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
|
||||
- Model: `Unsupervised\Schedular\Offering\Offering`
|
||||
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`)
|
||||
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
|
||||
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
|
||||
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Offering/OfferingControllerTest.php`
|
||||
- `tests/Unit/Offering/OfferingRepositoryTest.php`
|
||||
- `tests/Unit/Offering/OfferingTest.php`
|
||||
- `tests/Unit/Offering/OfferingEndpointTest.php`
|
||||
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`
|
||||
|
||||
@@ -88,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
|
||||
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
|
||||
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
|
||||
| `due_date` | DATE | When a *scheduled* payment is due; NULL = due at registration (`Payment::isScheduled()`) |
|
||||
| `period_key` | VARCHAR(20) | Scheduled-billing dedup key: session date (weekly) or `YYYY-MM` (monthly); NULL otherwise |
|
||||
| `notice_batch` | VARCHAR(32) | Shared reference for the payments one due-notice email covers, so a lump-sum e-transfer reconciles to them; NULL otherwise |
|
||||
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
|
||||
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
|
||||
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
|
||||
@@ -102,6 +105,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`.
|
||||
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
|
||||
| Method | Endpoint | Permission |
|
||||
|---------|---------------------------------------------|-----------------------------|
|
||||
|
||||
@@ -50,9 +50,16 @@ update for a same-slug plugin and makes core fire the
|
||||
4. When newer, returns the release's first `.zip` asset as the update
|
||||
package. Core takes over from there: Plugins-screen notice, one-click
|
||||
update, and WP-Cron auto-updates if enabled.
|
||||
5. When not newer — the site is current, or the lookup failed — returns a
|
||||
`no_update` payload (installed version, empty package). This keeps the
|
||||
plugin in core's `update_plugins` transient so core's `update-supported`
|
||||
flag stays set and the **Enable auto-updates** toggle shows on the
|
||||
Plugins screen. Without it, an off-directory plugin is absent from the
|
||||
transient between releases and the toggle never appears.
|
||||
|
||||
Any API failure, malformed response, or asset-less release degrades to
|
||||
"no update available" — never an error surfaced to the site.
|
||||
"no update available" (the `no_update` payload) — never an error surfaced
|
||||
to the site, and never a lost auto-update toggle during a Gitea blip.
|
||||
|
||||
## Cutting a Release
|
||||
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
# Feature: Registration Questions
|
||||
|
||||
## Overview
|
||||
Each offering can carry a set of intake questions the registrant must answer when booking. Questions are authored per offering by the studio admin or the owning instructor, and answers are stored against the resulting lesson or group enrolment.
|
||||
Questions come in two **scopes**:
|
||||
|
||||
- **Offering scope** (`scope = 'offering'`) — intake questions a registrant answers when
|
||||
booking a specific offering; authored per offering by the studio admin or the owning
|
||||
instructor, and stored against the resulting lesson or group enrolment.
|
||||
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers
|
||||
**once at account signup**, as a required second step after choosing their name and
|
||||
password. Authored by the studio admin only, and stored against the new user account.
|
||||
|
||||
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
|
||||
and the same authoring page (**Offerings → Questions**).
|
||||
|
||||
## Data Model — `{prefix}us_questions`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---------------|------------------|-------------------------------------------------------------|
|
||||
| `id` | BIGINT UNSIGNED | Primary key |
|
||||
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` — questions are scoped per offering |
|
||||
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` for offering-scoped questions; NULL for account-scoped |
|
||||
| `scope` | VARCHAR(20) | `offering` (default) or `account` |
|
||||
| `label` | VARCHAR(255) | The question text shown to the registrant |
|
||||
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
|
||||
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
|
||||
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
|
||||
| `sort_order` | INT | Display order within the offering |
|
||||
| `sort_order` | INT | Display order within the scope |
|
||||
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
@@ -23,27 +34,37 @@ Each offering can carry a set of intake questions the registrant must answer whe
|
||||
|---------------------|------------------|--------------------------------------------------------|
|
||||
| `id` | BIGINT UNSIGNED | Primary key |
|
||||
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
|
||||
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
|
||||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
|
||||
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
|
||||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) |
|
||||
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
|
||||
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
The `registration_type` + `registration_id` pair is a polymorphic reference shared
|
||||
with `us_policy_acceptances` (see `policies.md`), letting answers attach to either a
|
||||
private lesson or a group enrolment.
|
||||
with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private
|
||||
lesson, a group enrolment, or an account signup (`account` + the user ID).
|
||||
|
||||
## Flow
|
||||
1. On the registration form, the front-end calls `GET /offerings/{id}/questions`.
|
||||
## Offering-scope Flow
|
||||
1. On the booking form, the front-end calls `GET /offerings/{id}/questions`.
|
||||
2. Required questions block submission until answered.
|
||||
3. Answers are sent in the `answers[]` array on `POST /bookings` or `POST /enrollments` and written to `us_question_answers` alongside the new registration row.
|
||||
|
||||
## Account-scope Flow (signup step two)
|
||||
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
|
||||
2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. `assets/js/register.js` reveals step two behind a "Next" button (progressive enhancement — without JS both steps show and the single submit still works). This applies to **every** signup path (invite, group link, self-approval).
|
||||
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
|
||||
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
|
||||
|
||||
## Admin Interface
|
||||
Questions are edited from each offering's screen (**Offerings → Questions**).
|
||||
- Studio admin (`manage_questions`) edits questions on any offering.
|
||||
- Instructor (`manage_questions`) edits questions only on their own offerings.
|
||||
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
|
||||
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
|
||||
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
|
||||
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
|
||||
|
||||
## REST API
|
||||
Only offering-scope questions are exposed over REST. Account-scope questions are managed
|
||||
through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|---------------------------------------------------|----------------------|
|
||||
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public |
|
||||
@@ -52,12 +73,19 @@ Questions are edited from each offering's screen (**Offerings → Questions**).
|
||||
| `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
|
||||
|
||||
## Implementation
|
||||
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository`
|
||||
- Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer`
|
||||
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
|
||||
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
|
||||
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint`
|
||||
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
|
||||
- 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`
|
||||
- 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/Unit/Registration/QuestionRepositoryTest.php`
|
||||
- `tests/Unit/Registration/AnswerRepositoryTest.php`
|
||||
- `tests/Unit/Registration/QuestionTest.php`
|
||||
- `tests/Unit/Registration/AnswerTest.php`
|
||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php`
|
||||
|
||||
@@ -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)
|
||||
@@ -33,6 +33,10 @@ No new tables. The views are composed from existing data:
|
||||
and when it was accepted.
|
||||
- **Intake answers** — every registration-question answer, newest first:
|
||||
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:
|
||||
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
|
||||
as student-initiated cancellation: the lesson is marked `cancelled`, the
|
||||
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
|
||||
its capacity seat), with the same pending-payment voiding.
|
||||
|
||||
|
||||
+20
-6
@@ -17,11 +17,15 @@ use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
|
||||
use Unsupervised\Schedular\Offering\OfferingController;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentController;
|
||||
use Unsupervised\Schedular\Payment\PaymentReportController;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
@@ -53,16 +57,16 @@ class AdminMenu {
|
||||
private PaymentController $paymentController;
|
||||
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, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
|
||||
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->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||
$this->offeringController = new OfferingController( $offerings );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
|
||||
$this->registrationApprovalController = new RegistrationApprovalController( $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, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
|
||||
$this->instructorController = new InstructorController();
|
||||
$this->settings = $settings;
|
||||
$this->accessSettings = new AccessSettings();
|
||||
@@ -247,6 +251,16 @@ class AdminMenu {
|
||||
'dashicons-welcome-learn-more',
|
||||
42
|
||||
);
|
||||
|
||||
// Instructor: their own group classes with per-class rosters.
|
||||
add_submenu_page(
|
||||
'us-my-lessons',
|
||||
__( 'My Group Classes', 'unsupervised-schedular' ),
|
||||
__( 'My Group Classes', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_VIEW_LESSONS,
|
||||
'us-my-group-classes',
|
||||
[ $this->groupClassController, 'renderInstructorPage' ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class Invite {
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly string $kind = self::KIND_PERSONAL,
|
||||
public readonly ?string $expiresAt = null,
|
||||
public readonly ?int $offeringId = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -66,6 +67,7 @@ class Invite {
|
||||
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
|
||||
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
|
||||
offeringId: Val::intOrNull( $row->offering_id ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -132,6 +134,7 @@ class Invite {
|
||||
'accepted_user_id' => $this->acceptedUserId,
|
||||
'accepted_at' => $this->acceptedAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'offering_id' => $this->offeringId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class InviteRepository {
|
||||
'token' => $invite->token,
|
||||
'role' => $invite->role,
|
||||
'kind' => $invite->kind,
|
||||
'offering_id' => $invite->offeringId,
|
||||
'status' => $invite->status,
|
||||
'invited_by' => $invite->invitedBy,
|
||||
'accepted_user_id' => $invite->acceptedUserId,
|
||||
@@ -30,7 +31,7 @@ class InviteRepository {
|
||||
'accepted_at' => $invite->acceptedAt,
|
||||
'expires_at' => $invite->expiresAt,
|
||||
],
|
||||
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ]
|
||||
[ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return false === $result ? 0 : $this->db->insert_id;
|
||||
|
||||
@@ -105,6 +105,56 @@ class RegistrationMailer {
|
||||
return (bool) wp_mail( $email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell a registered student they have been given access to an invite-only
|
||||
* group class and can now enrol. Returns false when there is no recipient.
|
||||
*/
|
||||
public function sendClassAccessGranted( \WP_User $user, string $className ): bool {
|
||||
if ( '' === (string) $user->user_email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: class title */
|
||||
__( 'You have been invited to %s', 'unsupervised-schedular' ),
|
||||
$className
|
||||
);
|
||||
$body = sprintf(
|
||||
/* translators: 1: class title, 2: site name, 3: login URL */
|
||||
__( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ),
|
||||
$className,
|
||||
$this->siteName(),
|
||||
wp_login_url()
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $user->user_email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Email a tokenised registration link to someone invited to a group class who
|
||||
* does not yet have an account. Returns false when there is no recipient.
|
||||
*/
|
||||
public function sendClassInvite( string $email, string $link, string $className ): bool {
|
||||
if ( '' === $email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: class title */
|
||||
__( 'You are invited to join %s', 'unsupervised-schedular' ),
|
||||
$className
|
||||
);
|
||||
$body = sprintf(
|
||||
/* translators: 1: class title, 2: site name, 3: registration URL */
|
||||
__( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ),
|
||||
$className,
|
||||
$this->siteName(),
|
||||
$link
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $email, $subject, $body );
|
||||
}
|
||||
|
||||
private function siteName(): string {
|
||||
$name = (string) get_bloginfo( 'name' );
|
||||
|
||||
|
||||
+179
-14
@@ -3,12 +3,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
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\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class RegistrationPage {
|
||||
@@ -25,6 +30,13 @@ class RegistrationPage {
|
||||
*/
|
||||
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(
|
||||
private InviteRepository $invites,
|
||||
private PolicyRepository $policies,
|
||||
@@ -32,20 +44,37 @@ class RegistrationPage {
|
||||
private AcceptanceRepository $acceptances,
|
||||
private StudioSettings $settings,
|
||||
private RegistrationMailer $mailer,
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private GroupAccessRepository $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Renders the student registration shortcode output.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
|
||||
* `inviteOnlyMessage`) or shortcode
|
||||
* attributes (`login_page_id`,
|
||||
* `invite_only_message`).
|
||||
*/
|
||||
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 ( 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>';
|
||||
}
|
||||
|
||||
// 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'] ?? '' ) ) );
|
||||
// Only the token's hash is stored, so hash the submitted token for lookup.
|
||||
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||
@@ -57,17 +86,12 @@ class RegistrationPage {
|
||||
// fail to submit — the stale invite's address.
|
||||
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||
|
||||
$error = '';
|
||||
$successType = '';
|
||||
|
||||
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
|
||||
$result = $this->handleSubmit( $invite, $open );
|
||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
||||
$successType = $result;
|
||||
} else {
|
||||
$error = $result;
|
||||
}
|
||||
}
|
||||
// The submission itself is processed in maybeHandleSubmit on
|
||||
// template_redirect (before any output), so the invite auto-login cookie
|
||||
// is actually sent. Its success signal returns here as ?us_registered;
|
||||
// only a validation error is carried on the instance to show inline.
|
||||
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
|
||||
$error = $this->submitError;
|
||||
|
||||
// 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.
|
||||
@@ -77,13 +101,91 @@ class RegistrationPage {
|
||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$canRegister = $open || $inviteValid;
|
||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
}
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
||||
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
|
||||
* elsewhere (e.g. a link generated before the page was selected). Hooked on
|
||||
@@ -156,6 +258,17 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// Account-signup questions (step two) — validate before creating the user so
|
||||
// a missing required answer never leaves a half-registered account behind.
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( email_exists( $email ) ) {
|
||||
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
||||
}
|
||||
@@ -175,10 +288,16 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
// A personal invite may carry a group-class grant (invited by email);
|
||||
// point any grants for this address at the new account so the class
|
||||
// becomes enrollable for them.
|
||||
$this->access->linkStudentByEmail( $email, (int) $userId );
|
||||
|
||||
wp_set_current_user( (int) $userId );
|
||||
wp_set_auth_cookie( (int) $userId );
|
||||
|
||||
@@ -228,6 +347,52 @@ class RegistrationPage {
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function submittedAnswers(): array {
|
||||
// The submit nonce is verified by the caller (render) before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below.
|
||||
$raw = $_POST['us_answers'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $questionId => $value ) {
|
||||
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the submitted answers for each active account-signup question.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function recordAnswers( array $questions, array $answers, int $userId ): void {
|
||||
foreach ( $questions as $question ) {
|
||||
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
|
||||
if ( '' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->answers->insert(
|
||||
new Answer(
|
||||
questionId: (int) $question->id,
|
||||
registrationType: Answer::REG_ACCOUNT,
|
||||
registrationId: $userId,
|
||||
studentId: $userId,
|
||||
answerValue: $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record account-time acceptances for each signup policy version.
|
||||
*
|
||||
|
||||
@@ -27,8 +27,9 @@ class StudentActions {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* their payment — refunds are a manual, admin-side decision.
|
||||
* slot for rebooking, and voids a still-pending payment. A paid lesson is
|
||||
* 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 {
|
||||
$lesson = $this->bookings->findById( $lessonId );
|
||||
@@ -40,6 +41,7 @@ class StudentActions {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED );
|
||||
$this->availability->release( $lesson->slotId );
|
||||
$this->payments->voidPending( $lesson->paymentId );
|
||||
$this->payments->creditForCancelledLesson( $lesson );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -146,13 +146,27 @@ class StudentController {
|
||||
);
|
||||
|
||||
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
|
||||
$registrationInfo = $this->history->registrationInfo( (int) $student->ID );
|
||||
$intake = $this->history->intakeAnswers( (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' );
|
||||
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).
|
||||
*
|
||||
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Payment\Credit;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
@@ -11,6 +13,7 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,7 @@ class StudentHistory {
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private PaymentRepository $payments,
|
||||
private CreditRepository $credits,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,12 +55,20 @@ class StudentHistory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every intake answer the student has submitted, newest registration first.
|
||||
* Booking/enrolment intake answers the student has submitted, newest first.
|
||||
* Account-signup answers are excluded — those are shown on their own under
|
||||
* {@see registrationInfo()}.
|
||||
*
|
||||
* @return list<array{question: string, answer: string, context: string}>
|
||||
*/
|
||||
public function intakeAnswers( int $studentId ): array {
|
||||
return array_map(
|
||||
$bookingAnswers = array_filter(
|
||||
$this->answers->findByStudent( $studentId ),
|
||||
static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType
|
||||
);
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
|
||||
@@ -66,7 +78,35 @@ class StudentHistory {
|
||||
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
|
||||
];
|
||||
},
|
||||
$this->answers->findByStudent( $studentId )
|
||||
$bookingAnswers
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The student's answers to the studio-wide account-signup questions: every
|
||||
* configured account question paired with the student's answer ("—" when
|
||||
* unanswered, e.g. a question added after they registered).
|
||||
*
|
||||
* @return list<array{question: string, answer: string, required: bool}>
|
||||
*/
|
||||
public function registrationInfo( int $studentId ): array {
|
||||
$byQuestion = [];
|
||||
foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) {
|
||||
$byQuestion[ $answer->questionId ] = $answer->answerValue ?? '';
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static function ( Question $question ) use ( $byQuestion ): array {
|
||||
$value = $byQuestion[ (int) $question->id ] ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question->label,
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
'required' => $question->isRequired,
|
||||
];
|
||||
},
|
||||
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Resolves a person's public-facing name for display. Prefers their real name
|
||||
* (first + last), then their nickname — deliberately avoiding the account's
|
||||
* login/username, which `display_name` can otherwise expose.
|
||||
*/
|
||||
class UserName {
|
||||
|
||||
/**
|
||||
* The display name for a user: "First Last" when a real name is set,
|
||||
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
|
||||
* string when none is given) when the user cannot be loaded or has no name.
|
||||
*/
|
||||
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return $fallbackId > 0 ? (string) $fallbackId : '';
|
||||
}
|
||||
|
||||
$full = trim( $user->first_name . ' ' . $user->last_name );
|
||||
if ( '' !== $full ) {
|
||||
return $full;
|
||||
}
|
||||
|
||||
$nickname = trim( $user->nickname );
|
||||
if ( '' !== $nickname ) {
|
||||
return $nickname;
|
||||
}
|
||||
|
||||
return $fallbackId > 0 ? (string) $fallbackId : '';
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,28 @@ class AvailabilityRepository {
|
||||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* An instructor's slots (booked and unbooked) that overlap a time window —
|
||||
* they share any time with the half-open interval [$start, $end). Used when a
|
||||
* group class is scheduled to find the private-booking slots that collide with
|
||||
* it, so open ones can be cleared and booked ones flagged as conflicts.
|
||||
*
|
||||
* @return list<AvailabilitySlot>
|
||||
*/
|
||||
public function findOverlapping( int $instructorId, string $start, string $end ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE instructor_id = %d AND start_dt < %s AND end_dt > %s ORDER BY start_dt ASC',
|
||||
$this->table,
|
||||
$instructorId,
|
||||
$end,
|
||||
$start
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?AvailabilitySlot {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||||
|
||||
+20
-4
@@ -58,13 +58,29 @@ class BlockPreview {
|
||||
);
|
||||
}
|
||||
|
||||
public static function groupClasses(): string {
|
||||
/**
|
||||
* Sample group-class card.
|
||||
*
|
||||
* @param bool $singleClass Whether the block is pinned to one class, in
|
||||
* which case the live page omits the class
|
||||
* description and the preview does too.
|
||||
*/
|
||||
public static function groupClasses( bool $singleClass = false ): string {
|
||||
$note = $singleClass
|
||||
? __( 'Editor preview — the published page shows the chosen class with its live schedule and enrolment status.', 'unsupervised-schedular' )
|
||||
: __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' );
|
||||
|
||||
$description = $singleClass
|
||||
? ''
|
||||
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
|
||||
|
||||
return sprintf(
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
self::note( $note ),
|
||||
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||
esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ),
|
||||
$description,
|
||||
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,10 @@ class BlockRegistrar {
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
'inviteOnlyMessage' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/group-classes' => [
|
||||
@@ -156,7 +160,11 @@ class BlockRegistrar {
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderGroupClasses( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
|
||||
if ( ! $this->isEditorPreview() ) {
|
||||
return $this->groupClassPage->render( $attributes );
|
||||
}
|
||||
|
||||
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ class BookingEndpoint {
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private CancellationPolicy $cancellationPolicy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -122,17 +123,27 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson's array form plus its slot's start/end times, so front-end lists
|
||||
* can show when the session happens without a second request.
|
||||
* A lesson's array form plus its slot's start/end times and the booked
|
||||
* offering's name, so front-end lists can show what the session is and when
|
||||
* it happens without a second request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonWithTimes( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
// Prefer the offering's own length; fall back to the slot's when the
|
||||
// offering has none (a generic, duration-less type).
|
||||
$duration = null !== $offering && null !== $offering->durationMinutes
|
||||
? $offering->durationMinutes
|
||||
: $slot?->durationMinutes;
|
||||
|
||||
return $lesson->toArray() + [
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'offering_title' => $offering?->title,
|
||||
'duration_minutes' => $duration,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -248,7 +259,16 @@ class BookingEndpoint {
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
// charged at booking instead, so it is never missed or billed late.
|
||||
$chargeAtBooking = $offering->price > 0.0 && (
|
||||
! $offering->isScheduledBilling()
|
||||
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
|
||||
);
|
||||
|
||||
if ( $chargeAtBooking ) {
|
||||
// A full-term price already covers the whole reservation; a per-lesson
|
||||
// (one_time) price is owed once per occurrence actually claimed, so a
|
||||
// weekly reservation cannot hold a term while paying for one week.
|
||||
@@ -262,8 +282,10 @@ class BookingEndpoint {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Free offering: there is no payment step that would confirm these
|
||||
// lessons later, so they are confirmed at booking time.
|
||||
// Either a free offering, or scheduled billing (weekly / monthly) whose
|
||||
// payment is deferred to the daily billing scan. Either way there is no
|
||||
// payment step now to confirm the lessons, so the reserved slots are
|
||||
// confirmed at booking time; the billing scan bills them when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
@@ -295,6 +317,23 @@ class BookingEndpoint {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a given session has
|
||||
* already passed at booking time. Weekly bills 24 hours before the lesson;
|
||||
* monthly bills on the 1st, so its due moment has passed once "now" is in the
|
||||
* lesson's month or later. Only meaningful for weekly / monthly offerings.
|
||||
*/
|
||||
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
|
||||
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
|
||||
$start = new \DateTimeImmutable( $slotStart );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
|
||||
}
|
||||
|
||||
return $now >= $start->modify( '-1 day' );
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
@@ -304,8 +343,9 @@ class BookingEndpoint {
|
||||
|
||||
/**
|
||||
* Student-initiated cancellation of their own lesson: marks it cancelled,
|
||||
* frees the slot for rebooking, and voids any still-pending payment. Paid
|
||||
* lessons keep their payment — refunds are a manual, admin-side decision.
|
||||
* frees the slot for rebooking, and voids any still-pending payment. A lesson
|
||||
* 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 {
|
||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||
@@ -320,9 +360,27 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
if ( null !== $slot ) {
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
$overrideHours = $offering?->cancellationCutoffHours;
|
||||
if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) {
|
||||
return new \WP_Error(
|
||||
'cancellation_closed',
|
||||
sprintf(
|
||||
/* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */
|
||||
__( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ),
|
||||
$this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) )
|
||||
),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
|
||||
$this->availability->release( $lesson->slotId );
|
||||
$this->payments->voidPending( $lesson->paymentId );
|
||||
$this->payments->creditForCancelledLesson( $lesson );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response(
|
||||
@@ -351,6 +409,7 @@ class BookingEndpoint {
|
||||
if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||
$this->availability->release( $lesson->slotId );
|
||||
$this->payments->voidPending( $lesson->paymentId );
|
||||
$this->payments->creditForCancelledLesson( $lesson );
|
||||
} 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
|
||||
// someone else may have booked the freed time in the meantime.
|
||||
|
||||
@@ -204,6 +204,76 @@ class BookingRepository {
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering:
|
||||
* status not cancelled and no payment attached yet. Each row carries the slot
|
||||
* start time and the offering's billing fields so the daily billing scan can
|
||||
* decide what is due without a second query per lesson. Ordered by student,
|
||||
* offering and time so the scan can group a student's monthly lessons cheaply.
|
||||
*
|
||||
* @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id,
|
||||
* start_dt, billing_mode, title, price, currency,
|
||||
* etransfer_email.
|
||||
*/
|
||||
public function findUnbilledScheduledLessons(): array {
|
||||
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||
$offTable = str_replace( 'us_lessons', 'us_offerings', $this->table );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT l.id, l.student_id, l.instructor_id, l.offering_id,
|
||||
a.start_dt,
|
||||
o.billing_mode, o.title, o.price, o.currency, o.etransfer_email
|
||||
FROM %i l
|
||||
JOIN %i a ON a.id = l.slot_id
|
||||
JOIN %i o ON o.id = l.offering_id
|
||||
WHERE l.status != %s
|
||||
AND l.payment_id IS NULL
|
||||
AND o.billing_mode IN ( %s, %s )
|
||||
ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC',
|
||||
$this->table,
|
||||
$avTable,
|
||||
$offTable,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY
|
||||
)
|
||||
);
|
||||
|
||||
return $rows ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
|
||||
/**
|
||||
* Decides whether a student may still cancel a lesson. Cancellation closes once
|
||||
* the lesson starts within the effective cutoff window; instructors and studio
|
||||
* admins bypass this entirely and cancel through other paths.
|
||||
*
|
||||
* The window is resolved per lesson: the offering's own cutoff when it sets one,
|
||||
* otherwise the studio default. Both are expressed in hours.
|
||||
*/
|
||||
class CancellationPolicy {
|
||||
|
||||
public function __construct( private StudioSettings $settings ) {}
|
||||
|
||||
/**
|
||||
* The effective cutoff in hours for a lesson: the offering's override when
|
||||
* set (a non-negative value), otherwise the studio default.
|
||||
*/
|
||||
public function cutoffHours( ?int $offeringCutoffHours ): int {
|
||||
if ( null !== $offeringCutoffHours && $offeringCutoffHours >= 0 ) {
|
||||
return $offeringCutoffHours;
|
||||
}
|
||||
|
||||
return $this->settings->cancellationCutoffHours();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a student may still cancel a lesson starting at $slotStartDt
|
||||
* (WordPress-local `Y-m-d H:i:s`), given the offering's optional cutoff
|
||||
* override. A zero cutoff always allows cancellation; unparseable input
|
||||
* fails open so a student is never trapped by bad data. Pass $now to make
|
||||
* the comparison deterministic in tests.
|
||||
*/
|
||||
public function studentMayCancel( string $slotStartDt, ?int $offeringCutoffHours, ?string $now = null ): bool {
|
||||
$hours = $this->cutoffHours( $offeringCutoffHours );
|
||||
if ( $hours <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$start = strtotime( $slotStartDt );
|
||||
$current = strtotime( $now ?? current_time( 'mysql' ) );
|
||||
if ( false === $start || false === $current ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ( $start - $current ) >= $hours * 3600;
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-readable description of a cutoff for student-facing messages:
|
||||
* whole days as days, anything else as hours.
|
||||
*/
|
||||
public function describeCutoff( int $hours ): string {
|
||||
if ( $hours > 0 && 0 === $hours % 24 ) {
|
||||
$days = $hours / 24;
|
||||
|
||||
/* translators: %d: number of days. */
|
||||
return sprintf( _n( '%d day', '%d days', $days, 'unsupervised-schedular' ), $days );
|
||||
}
|
||||
|
||||
/* translators: %d: number of hours. */
|
||||
return sprintf( _n( '%d hour', '%d hours', $hours, 'unsupervised-schedular' ), $hours );
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WeekCalendar;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
@@ -17,6 +18,8 @@ class LessonController {
|
||||
private BookingRepository $repository,
|
||||
private PaymentRepository $payments,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private LessonDetail $detail,
|
||||
) {}
|
||||
|
||||
public function renderAdminDashboard(): void {
|
||||
@@ -24,6 +27,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( false );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
|
||||
@@ -36,6 +43,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( true );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
|
||||
@@ -43,6 +54,36 @@ class LessonController {
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the request targets a single lesson (`?lesson_id=`), render its detail
|
||||
* view and report that the page has been handled. Instructors may only open
|
||||
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
|
||||
*/
|
||||
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
|
||||
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
|
||||
if ( $lessonId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lesson = $this->repository->findById( $lessonId );
|
||||
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
||||
|
||||
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
|
||||
$row = null;
|
||||
$answers = [];
|
||||
$accepts = [];
|
||||
} else {
|
||||
$row = $this->row( $lesson );
|
||||
$answers = $this->detail->answers( $lessonId );
|
||||
$accepts = $this->detail->acceptances( $lessonId );
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the lessons template with its calendar view state: week (default)
|
||||
* or list, plus which week the week view shows.
|
||||
@@ -111,10 +152,15 @@ class LessonController {
|
||||
$instructor = get_userdata( $lesson->instructorId );
|
||||
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
return [
|
||||
'lesson_id' => (int) $lesson->id,
|
||||
'student' => $student ? $student->display_name : (string) $lesson->studentId,
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
|
||||
'offering' => $offering ? $offering->title : '—',
|
||||
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
|
||||
'recurrence' => $lesson->recurrence,
|
||||
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
|
||||
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
|
||||
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for the admin lesson detail view: the intake answers
|
||||
* the student submitted and the policy versions they accepted when booking.
|
||||
*
|
||||
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
||||
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*/
|
||||
class LessonDetail {
|
||||
|
||||
public function __construct(
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The intake-question answers recorded for this lesson, in submission order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string}>
|
||||
*/
|
||||
public function answers( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
$value = $answer->answerValue ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted when booking this lesson, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
||||
*/
|
||||
public function acceptances( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( PolicyAcceptance $acceptance ): array {
|
||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
return [
|
||||
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
|
||||
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
'accepted_at' => $acceptance->acceptedAt ?? '',
|
||||
'ip' => $acceptance->ipAddress ?? '',
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class EnrollmentEndpoint {
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private GroupAccessRepository $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -58,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
|
||||
@@ -88,6 +101,18 @@ class EnrollmentEndpoint {
|
||||
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
// Invite-only classes can only be enrolled in by students who were granted
|
||||
// access (or added directly); everyone else never sees the class at all.
|
||||
if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) {
|
||||
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day — the instructor's set
|
||||
// deadline, or the first class day by default.
|
||||
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
|
||||
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
|
||||
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
@@ -110,8 +135,17 @@ class EnrollmentEndpoint {
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
|
||||
// Mark the access grant used so instructor rosters distinguish invited
|
||||
// students from enrolled ones (a no-op for public classes).
|
||||
if ( $offering->isInviteOnly() ) {
|
||||
$this->access->markEnrolled( $offeringId, $studentId );
|
||||
}
|
||||
|
||||
// Scheduled billing (weekly / monthly) is generated later by the daily
|
||||
// billing scan, so nothing is charged at enrolment; the enrolment is active
|
||||
// regardless of payment.
|
||||
$payment = null;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
}
|
||||
|
||||
@@ -126,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 {
|
||||
return is_user_logged_in();
|
||||
}
|
||||
|
||||
@@ -132,6 +132,39 @@ class EnrollmentRepository {
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Active enrolments whose group class bills on a scheduled mode (weekly /
|
||||
* monthly) — the source rows for the daily billing scan. Filtered by joining
|
||||
* the offering so only classes actually on a scheduled plan are returned.
|
||||
*
|
||||
* @param list<string> $modes Billing modes to include (e.g. weekly, monthly).
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findActiveByBillingModes( array $modes ): array {
|
||||
if ( [] === $modes ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table );
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT e.* FROM %i e
|
||||
JOIN %i o ON o.id = e.offering_id
|
||||
WHERE e.status = %s
|
||||
AND o.billing_mode IN ( {$placeholders} )
|
||||
ORDER BY e.student_id ASC, e.offering_id ASC",
|
||||
$this->table,
|
||||
$offTable,
|
||||
Enrollment::STATUS_ACTIVE,
|
||||
...$modes
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function setPaymentId( int $id, int $paymentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* A grant of access to an invite-only group class. Registered students who have
|
||||
* been "made available" a class hold an `invited` grant (`student_id` set);
|
||||
* email-invited people who do not yet have an account hold a grant keyed by
|
||||
* `email` and linked to a `us_invites` row, which is pointed at the new
|
||||
* `student_id` once they register. A grant flips to `enrolled` when the student
|
||||
* enrols through the normal flow.
|
||||
*/
|
||||
class GroupAccess {
|
||||
|
||||
public const STATUS_INVITED = 'invited';
|
||||
public const STATUS_ENROLLED = 'enrolled';
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
|
||||
/**
|
||||
* All valid grant statuses.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $offeringId,
|
||||
public readonly ?int $studentId = null,
|
||||
public readonly string $email = '',
|
||||
public readonly ?int $inviteId = null,
|
||||
public readonly string $status = self::STATUS_INVITED,
|
||||
public readonly ?int $invitedBy = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
offeringId: Val::int( $row->offering_id ),
|
||||
studentId: Val::intOrNull( $row->student_id ),
|
||||
email: Val::string( $row->email ?? '' ),
|
||||
inviteId: Val::intOrNull( $row->invite_id ?? null ),
|
||||
status: Val::string( $row->status ),
|
||||
invitedBy: Val::intOrNull( $row->invited_by ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the grant.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'offering_id' => $this->offeringId,
|
||||
'student_id' => $this->studentId,
|
||||
'email' => $this->email,
|
||||
'invite_id' => $this->inviteId,
|
||||
'status' => $this->status,
|
||||
'invited_by' => $this->invitedBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
class GroupAccessRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_group_access';
|
||||
}
|
||||
|
||||
public function insert( GroupAccess $access ): int {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'offering_id' => $access->offeringId,
|
||||
'student_id' => $access->studentId,
|
||||
'email' => $access->email,
|
||||
'invite_id' => $access->inviteId,
|
||||
'status' => $access->status,
|
||||
'invited_by' => $access->invitedBy,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a student holds a live (invited or enrolled) grant for an offering.
|
||||
*/
|
||||
public function hasGrant( int $offeringId, int $studentId ): bool {
|
||||
$count = (int) $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )',
|
||||
$this->table,
|
||||
$offeringId,
|
||||
$studentId,
|
||||
GroupAccess::STATUS_INVITED,
|
||||
GroupAccess::STATUS_ENROLLED
|
||||
)
|
||||
);
|
||||
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The offering ids a student holds a live grant for — the invite-only classes
|
||||
* to fold into their catalogue view.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function findGrantedOfferingIds( int $studentId ): array {
|
||||
$rows = $this->db->get_col(
|
||||
$this->db->prepare(
|
||||
'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )',
|
||||
$this->table,
|
||||
$studentId,
|
||||
GroupAccess::STATUS_INVITED,
|
||||
GroupAccess::STATUS_ENROLLED
|
||||
)
|
||||
);
|
||||
|
||||
return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* All grants for an offering, newest first.
|
||||
*
|
||||
* @return list<GroupAccess>
|
||||
*/
|
||||
public function findByOffering( int $offeringId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC',
|
||||
$this->table,
|
||||
$offeringId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( GroupAccess::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Point email-invite grants for an address at the account created when the
|
||||
* invitation was accepted, so the granted class unlocks for the new student.
|
||||
* Only grants still awaiting an account (`student_id` NULL) are linked.
|
||||
*/
|
||||
public function linkStudentByEmail( string $email, int $studentId ): bool {
|
||||
if ( '' === $email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL',
|
||||
$this->table,
|
||||
$studentId,
|
||||
$email
|
||||
);
|
||||
|
||||
return null !== $sql && false !== $this->db->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip a student's live grant for an offering to enrolled.
|
||||
*/
|
||||
public function markEnrolled( int $offeringId, int $studentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
[ 'status' => GroupAccess::STATUS_ENROLLED ],
|
||||
[
|
||||
'offering_id' => $offeringId,
|
||||
'student_id' => $studentId,
|
||||
],
|
||||
[ '%s' ],
|
||||
[ '%d', '%d' ]
|
||||
);
|
||||
}
|
||||
|
||||
public function revoke( int $id ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
[ 'status' => GroupAccess::STATUS_REVOKED ],
|
||||
[ 'id' => $id ],
|
||||
[ '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,35 +3,513 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\Invite;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class GroupClassController {
|
||||
|
||||
public function __construct(
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentRepository $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private PaymentService $paymentService,
|
||||
private InviteRepository $invites,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Studio-admin overview: every group class across instructors as a summary —
|
||||
* who teaches it, when it meets, and how full it is — rather than a flat list
|
||||
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
|
||||
* the same per-class details page instructors use, so a studio admin (including
|
||||
* an owner-operator who also teaches) can view any class's roster and manage
|
||||
* invite-only membership from here.
|
||||
*/
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
function ( Enrollment $enrollment ): array {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||
$notice = $this->handleFormAction( get_current_user_id() );
|
||||
}
|
||||
|
||||
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
|
||||
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
|
||||
|
||||
// View-state query param only (which class to drill into) — nothing is
|
||||
// mutated from it, so no nonce applies.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||||
$current = null;
|
||||
foreach ( $offerings as $offering ) {
|
||||
if ( $offering->id === $classId ) {
|
||||
$current = $offering;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null !== $current ) {
|
||||
// Enrolments are looked up by the class's own instructor; classDetail
|
||||
// filters them down to this offering.
|
||||
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
|
||||
$students = $this->studentOptions();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
function ( Offering $offering ): array {
|
||||
return [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
|
||||
'status' => $enrollment->status,
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'when' => $this->whenLabel( $offering ),
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
];
|
||||
},
|
||||
$this->enrollments->findAllActive()
|
||||
$offerings
|
||||
);
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructor view. By default a summary of the instructor's own group classes
|
||||
* — each with when it meets and how many are enrolled — rather than a dump of
|
||||
* every roster. A `class_id` query param drills into one class to show its
|
||||
* roster of enrolled students and, for invite-only classes, the controls to
|
||||
* add, grant access to, or email-invite students.
|
||||
*/
|
||||
public function renderInstructorPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||
$notice = $this->handleFormAction( $instructorId );
|
||||
}
|
||||
|
||||
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
|
||||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||||
|
||||
// View-state query param only (which class to drill into) — nothing is
|
||||
// mutated from it, so no nonce applies.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||||
$current = null;
|
||||
foreach ( $offerings as $offering ) {
|
||||
if ( $offering->id === $classId ) {
|
||||
$current = $offering;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null !== $current ) {
|
||||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||||
$class = $this->classDetail( $current, $enrollments );
|
||||
$students = $this->studentOptions();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$classes = array_map(
|
||||
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
|
||||
$offerings
|
||||
);
|
||||
|
||||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary row for one class in the instructor overview: its identity, when it
|
||||
* meets, and how many active enrolments it holds against capacity.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
|
||||
*/
|
||||
private function classSummary( Offering $offering, array $enrollments ): array {
|
||||
$enrolled = 0;
|
||||
foreach ( $enrollments as $enrollment ) {
|
||||
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||
++$enrolled;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'when' => $this->whenLabel( $offering ),
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full details for one class: the summary fields, the class's own settings
|
||||
* (instructor, price, duration, description, schedule, active state), the
|
||||
* roster of enrolled students (with enrolment and payment status), and — for
|
||||
* invite-only classes — the list of people invited but not yet enrolled.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
*/
|
||||
private function classDetail( Offering $offering, array $enrollments ): array {
|
||||
$roster = [];
|
||||
foreach ( $enrollments as $enrollment ) {
|
||||
if ( $enrollment->offeringId !== $offering->id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||||
|
||||
$roster[] = [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'status' => $enrollment->status,
|
||||
'payment' => $payment?->status,
|
||||
];
|
||||
}
|
||||
|
||||
$deadline = $offering->effectiveEnrollmentDeadline();
|
||||
|
||||
return $this->classSummary( $offering, $enrollments ) + [
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'duration' => $offering->durationMinutes,
|
||||
'description' => $offering->description,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
|
||||
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The teaching instructor's display name — their real name or nickname, never
|
||||
* the login. Falls back to the numeric id when the account is gone. See
|
||||
* {@see UserName::format()}.
|
||||
*/
|
||||
private function instructorName( Offering $offering ): string {
|
||||
$user = get_userdata( $offering->instructorId );
|
||||
|
||||
return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable "when" label for a class: the class date (or weekly date
|
||||
* range) and, when set, the start time. Empty when the class has no date.
|
||||
*/
|
||||
private function whenLabel( Offering $offering ): string {
|
||||
if ( null === $offering->termStart ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
|
||||
? (string) mysql2date( 'M j, Y', $offering->termStart )
|
||||
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' – ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
|
||||
|
||||
if ( null !== $offering->classTime ) {
|
||||
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
|
||||
* the instructor can see who has been invited but has not enrolled yet.
|
||||
*
|
||||
* @return list<array{who: string, kind: string}>
|
||||
*/
|
||||
private function pendingInvites( int $offeringId ): array {
|
||||
$out = [];
|
||||
foreach ( $this->access->findByOffering( $offeringId ) as $grant ) {
|
||||
if ( GroupAccess::STATUS_INVITED !== $grant->status ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null !== $grant->studentId ) {
|
||||
$user = get_userdata( $grant->studentId );
|
||||
$out[] = [
|
||||
'who' => $user ? $user->display_name : (string) $grant->studentId,
|
||||
'kind' => __( 'Granted', 'unsupervised-schedular' ),
|
||||
];
|
||||
} else {
|
||||
$out[] = [
|
||||
'who' => $grant->email,
|
||||
'kind' => __( 'Email invite', 'unsupervised-schedular' ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a posted management action, returning a status notice for display.
|
||||
* The action is scoped to a group class the current instructor owns, unless
|
||||
* the caller is a studio admin (`view_all_lessons`) — who may manage any
|
||||
* instructor's class, since the studio-admin Group Classes page reaches the
|
||||
* same controls for every class.
|
||||
*/
|
||||
private function handleFormAction( int $instructorId ): string {
|
||||
// Nonce is verified by the caller before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
|
||||
|
||||
$ownsOrManagesAll = null !== $offering
|
||||
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
|
||||
|
||||
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||||
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( 'add_direct' === $action ) {
|
||||
return $this->addDirect( $offering, $this->postedStudentIds() );
|
||||
}
|
||||
|
||||
if ( 'grant_access' === $action ) {
|
||||
return $this->grantAccess( $offering, $this->postedStudentIds() );
|
||||
}
|
||||
|
||||
if ( 'invite_email' === $action ) {
|
||||
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
|
||||
|
||||
return $this->inviteEmail( $offering, $email );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly enrol registered students, each with a pending payment at the
|
||||
* class price (comp students are settled immediately by the payment service).
|
||||
*
|
||||
* This is the instructor's manual enrolment path and deliberately bypasses the
|
||||
* enrolment deadline and capacity, so a student can be added as a late
|
||||
* enrolment after the class has closed to self-enrolment.
|
||||
*
|
||||
* @param list<int> $studentIds
|
||||
*/
|
||||
private function addDirect( Offering $offering, array $studentIds ): string {
|
||||
$added = 0;
|
||||
foreach ( $studentIds as $studentId ) {
|
||||
if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$enrollmentId = $this->enrollments->insert(
|
||||
new Enrollment(
|
||||
offeringId: (int) $offering->id,
|
||||
studentId: $studentId,
|
||||
instructorId: $offering->instructorId,
|
||||
)
|
||||
);
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
$payment = $this->paymentService->createForRegistration(
|
||||
Payment::REG_ENROLLMENT,
|
||||
$enrollmentId,
|
||||
$studentId,
|
||||
$offering->instructorId,
|
||||
$offering->price,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail
|
||||
);
|
||||
|
||||
if ( null !== $payment && null !== $payment->id ) {
|
||||
$this->enrollments->setPaymentId( $enrollmentId, $payment->id );
|
||||
}
|
||||
}
|
||||
|
||||
$this->access->markEnrolled( (int) $offering->id, $studentId );
|
||||
++$added;
|
||||
}
|
||||
|
||||
/* translators: %d: number of students added. */
|
||||
return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added );
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant registered students access to the class so it appears in their list
|
||||
* for self-enrolment, notifying each by email.
|
||||
*
|
||||
* @param list<int> $studentIds
|
||||
*/
|
||||
private function grantAccess( Offering $offering, array $studentIds ): string {
|
||||
$granted = 0;
|
||||
foreach ( $studentIds as $studentId ) {
|
||||
if (
|
||||
$this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId )
|
||||
|| $this->access->hasGrant( (int) $offering->id, $studentId )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->access->insert(
|
||||
new GroupAccess(
|
||||
offeringId: (int) $offering->id,
|
||||
studentId: $studentId,
|
||||
status: GroupAccess::STATUS_INVITED,
|
||||
invitedBy: get_current_user_id(),
|
||||
)
|
||||
);
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->sendClassAccessGranted( $user, $offering->title );
|
||||
}
|
||||
|
||||
++$granted;
|
||||
}
|
||||
|
||||
/* translators: %d: number of students granted access. */
|
||||
return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite someone by email. A registered address is treated as a grant; an
|
||||
* unknown address gets a tokenised registration invite tied to the class,
|
||||
* reusing any pending invite already outstanding for that address (in which
|
||||
* case no new link is sent).
|
||||
*/
|
||||
private function inviteEmail( Offering $offering, string $email ): string {
|
||||
if ( ! is_email( $email ) ) {
|
||||
return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$existingUserId = email_exists( $email );
|
||||
if ( false !== $existingUserId ) {
|
||||
return $this->grantAccess( $offering, [ (int) $existingUserId ] );
|
||||
}
|
||||
|
||||
// Reuse an outstanding invite rather than mailing a second link; still
|
||||
// attach a class grant so enrolment unlocks once they register.
|
||||
$pending = $this->invites->findPendingByEmail( $email );
|
||||
if ( null !== $pending ) {
|
||||
$this->access->insert(
|
||||
new GroupAccess(
|
||||
offeringId: (int) $offering->id,
|
||||
email: $email,
|
||||
inviteId: $pending->id,
|
||||
status: GroupAccess::STATUS_INVITED,
|
||||
invitedBy: get_current_user_id(),
|
||||
)
|
||||
);
|
||||
|
||||
return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$rawToken = wp_generate_password( 32, false );
|
||||
$inviteId = $this->invites->insert(
|
||||
new Invite(
|
||||
email: $email,
|
||||
token: Invite::hashToken( $rawToken ),
|
||||
invitedBy: get_current_user_id(),
|
||||
offeringId: (int) $offering->id,
|
||||
)
|
||||
);
|
||||
|
||||
if ( $inviteId <= 0 ) {
|
||||
return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$this->access->insert(
|
||||
new GroupAccess(
|
||||
offeringId: (int) $offering->id,
|
||||
email: $email,
|
||||
inviteId: $inviteId,
|
||||
status: GroupAccess::STATUS_INVITED,
|
||||
invitedBy: get_current_user_id(),
|
||||
)
|
||||
);
|
||||
|
||||
$this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title );
|
||||
|
||||
return esc_html__( 'Invitation sent.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered students to offer in the add/grant selects, by display name.
|
||||
*
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
private function studentOptions(): array {
|
||||
$users = array_filter(
|
||||
get_users(
|
||||
[
|
||||
'role' => RoleManager::STUDENT,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
),
|
||||
static fn( mixed $u ): bool => $u instanceof \WP_User
|
||||
);
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( \WP_User $u ): array => [
|
||||
'id' => (int) $u->ID,
|
||||
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
|
||||
],
|
||||
$users
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The de-duplicated positive student ids posted from a multi-select.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function postedStudentIds(): array {
|
||||
// Nonce is verified by the caller before this method runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion.
|
||||
$raw = (array) ( $_POST['student_ids'] ?? [] );
|
||||
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
|
||||
|
||||
return array_values( array_unique( $ids ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the registration URL for a raw invite token, mirroring the invites
|
||||
* admin page so class invites land on the same registration page.
|
||||
*/
|
||||
private function registrationLink( string $rawToken ): string {
|
||||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||||
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
|
||||
|
||||
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
|
||||
class Installer {
|
||||
|
||||
@@ -12,10 +13,22 @@ class Installer {
|
||||
$this->createTables();
|
||||
$this->migrateData();
|
||||
( new RoleManager() )->createRoles();
|
||||
$this->scheduleBilling();
|
||||
flush_rewrite_rules();
|
||||
update_option( 'us_schedular_version', USC_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the daily scheduled-billing scan is registered with WP-Cron. Runs on
|
||||
* activation and on every version-bump re-install, so an existing site that
|
||||
* predates the feature picks the event up on its next deploy.
|
||||
*/
|
||||
private function scheduleBilling(): void {
|
||||
if ( false === wp_next_scheduled( ScheduledBillingRunner::HOOK ) ) {
|
||||
wp_schedule_event( time(), 'daily', ScheduledBillingRunner::HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
private function createTables(): void {
|
||||
global $wpdb;
|
||||
if ( ! $wpdb instanceof \wpdb ) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
|
||||
/**
|
||||
* Keeps an instructor's open availability out of the way of the group classes
|
||||
* they teach. When a group class is scheduled (an assigned instructor plus a
|
||||
* date, time, and duration), each session occupies the instructor: any open
|
||||
* private-booking slot that overlaps a session is removed so students cannot
|
||||
* book the instructor at the class time, and any already-booked slot that
|
||||
* overlaps is reported as a conflict for the studio to resolve by hand — a
|
||||
* booked lesson is never silently deleted.
|
||||
*/
|
||||
class ClassSlotReconciler {
|
||||
|
||||
public function __construct( private AvailabilityRepository $availability ) {}
|
||||
|
||||
/**
|
||||
* Reconcile the assigned instructor's availability with the class schedule.
|
||||
*
|
||||
* @return array{removed: int, conflicts: list<string>} The number of open
|
||||
* slots cleared, and the start datetime (`Y-m-d H:i:s`) of each booked
|
||||
* slot that still clashes with a session.
|
||||
*/
|
||||
public function reconcile( Offering $offering ): array {
|
||||
if ( Offering::KIND_GROUP_CLASS !== $offering->kind || $offering->instructorId <= 0 ) {
|
||||
return [
|
||||
'removed' => 0,
|
||||
'conflicts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
$conflicts = [];
|
||||
|
||||
foreach ( $offering->sessionWindows() as $window ) {
|
||||
foreach ( $this->availability->findOverlapping( $offering->instructorId, $window['start'], $window['end'] ) as $slot ) {
|
||||
if ( $slot->isBooked ) {
|
||||
$conflicts[] = $slot->startDt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null !== $slot->id && $this->availability->delete( $slot->id ) ) {
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'removed' => $removed,
|
||||
'conflicts' => $conflicts,
|
||||
];
|
||||
}
|
||||
}
|
||||
+165
-1
@@ -20,12 +20,48 @@ class Offering {
|
||||
public const BILLING_ONE_TIME = 'one_time';
|
||||
public const BILLING_FULL_TERM = 'full_term';
|
||||
|
||||
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
|
||||
public const BILLING_WEEKLY = 'weekly';
|
||||
|
||||
/** Billed on the first of each month for every lesson that falls in the month. */
|
||||
public const BILLING_MONTHLY = 'monthly';
|
||||
|
||||
/**
|
||||
* All valid billing modes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/**
|
||||
* Billing modes whose payment is generated later by the daily billing scan
|
||||
* rather than taken at registration.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
|
||||
public const ACCESS_PUBLIC = 'public';
|
||||
|
||||
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
|
||||
public const ACCESS_INVITE_ONLY = 'invite_only';
|
||||
|
||||
/**
|
||||
* All valid access modes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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 readonly int $instructorId,
|
||||
@@ -40,12 +76,67 @@ class Offering {
|
||||
public readonly ?int $capacity = null,
|
||||
public readonly ?string $termStart = null,
|
||||
public readonly ?string $termEnd = null,
|
||||
public readonly ?string $classTime = null,
|
||||
public readonly ?string $enrollmentDeadline = null,
|
||||
public readonly ?string $withdrawalDeadline = null,
|
||||
public readonly ?string $scheduleNote = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?int $cancellationCutoffHours = null,
|
||||
public readonly string $accessMode = self::ACCESS_PUBLIC,
|
||||
public readonly bool $isActive = true,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether the offering is hidden from the public catalogue and reachable
|
||||
* only by invited or directly-added students.
|
||||
*/
|
||||
public function isInviteOnly(): bool {
|
||||
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
|
||||
* not a real calendar date. Round-trips through DateTimeImmutable so
|
||||
@@ -67,6 +158,69 @@ class Offering {
|
||||
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a submitted time-of-day to canonical `H:i:s`, or null when it is
|
||||
* not a real time. Accepts the HTML `time` form (`H:i`, optionally with
|
||||
* seconds); anything else is rejected so garbage never reaches the TIME column.
|
||||
*/
|
||||
public static function normalizeTime( string $value ): ?string {
|
||||
foreach ( [ 'H:i:s', 'H:i' ] as $format ) {
|
||||
$time = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
|
||||
if ( false !== $time && $time->format( $format ) === $value ) {
|
||||
return $time->format( 'H:i:s' );
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete start/end datetimes of every session of this group class,
|
||||
* derived from the class date(s), the class time, and the duration. A weekly
|
||||
* class yields one window per week from `term_start` through `term_end`; a
|
||||
* one-off class yields a single window. Returns an empty list unless the
|
||||
* schedule is fully specified (date, time, and a positive duration), so it can
|
||||
* never fabricate a session window from partial data.
|
||||
*
|
||||
* @return list<array{start: string, end: string}>
|
||||
*/
|
||||
public function sessionWindows(): array {
|
||||
if (
|
||||
null === $this->termStart
|
||||
|| null === $this->classTime
|
||||
|| null === $this->durationMinutes
|
||||
|| $this->durationMinutes <= 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$first = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $this->termStart . ' ' . $this->classTime );
|
||||
if ( false === $first ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
|
||||
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||||
|
||||
$windows = [];
|
||||
$cursor = $first;
|
||||
$cursorDay = $cursor->format( 'Y-m-d' );
|
||||
|
||||
// Cap the walk at ten years of weeks so a term_end before term_start (or a
|
||||
// bad value) can never spin into an unbounded loop.
|
||||
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
|
||||
$windows[] = [
|
||||
'start' => $cursor->format( 'Y-m-d H:i:s' ),
|
||||
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
|
||||
];
|
||||
|
||||
$cursor = $cursor->modify( '+7 days' );
|
||||
$cursorDay = $cursor->format( 'Y-m-d' );
|
||||
}
|
||||
|
||||
return $windows;
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
instructorId: Val::int( $row->instructor_id ),
|
||||
@@ -81,8 +235,13 @@ class Offering {
|
||||
capacity: Val::intOrNull( $row->capacity ),
|
||||
termStart: Val::stringOrNull( $row->term_start ),
|
||||
termEnd: Val::stringOrNull( $row->term_end ),
|
||||
classTime: Val::stringOrNull( $row->class_time ?? null ),
|
||||
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
|
||||
withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ),
|
||||
scheduleNote: Val::stringOrNull( $row->schedule_note ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
|
||||
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
|
||||
isActive: Val::bool( $row->is_active ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
@@ -112,7 +271,12 @@ class Offering {
|
||||
'capacity' => $this->capacity,
|
||||
'term_start' => $this->termStart,
|
||||
'term_end' => $this->termEnd,
|
||||
'class_time' => $this->classTime,
|
||||
'enrollment_deadline' => $this->enrollmentDeadline,
|
||||
'withdrawal_deadline' => $this->withdrawalDeadline,
|
||||
'schedule_note' => $this->scheduleNote,
|
||||
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
|
||||
'access_mode' => $this->accessMode,
|
||||
'is_active' => $this->isActive,
|
||||
];
|
||||
|
||||
|
||||
@@ -3,12 +3,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class OfferingController {
|
||||
|
||||
public function __construct( private OfferingRepository $repository ) {}
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private ClassSlotReconciler $reconciler,
|
||||
private AccessSettings $access = new AccessSettings(),
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
|
||||
@@ -18,10 +23,15 @@ class OfferingController {
|
||||
$instructorId = get_current_user_id();
|
||||
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
|
||||
$this->handleFormAction( $instructorId, $manageAll );
|
||||
$notice = $this->handleFormAction( $instructorId, $manageAll );
|
||||
}
|
||||
|
||||
// Studio admins may assign any instructor to a class; a plain instructor
|
||||
// only ever creates classes for themselves, so the picker is theirs alone.
|
||||
$instructors = $manageAll ? $this->instructorOptions() : [];
|
||||
|
||||
// View-state query param only (which offering the form is editing) —
|
||||
// nothing is mutated from it, so no nonce applies.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
@@ -43,15 +53,22 @@ class OfferingController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
|
||||
}
|
||||
|
||||
private function handleFormAction( int $instructorId, bool $manageAll ): void {
|
||||
/**
|
||||
* Process the posted add/update/delete action, returning a status notice for
|
||||
* display (e.g. how many booking slots a scheduled class cleared, or that a
|
||||
* booked lesson clashes with it). An empty string means nothing to report.
|
||||
*/
|
||||
private function handleFormAction( int $instructorId, bool $manageAll ): string {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
|
||||
if ( 'add' === $action ) {
|
||||
$offering = $this->offeringFromPost( $instructorId );
|
||||
$offering = $this->offeringFromPost( $instructorId, $manageAll );
|
||||
if ( null !== $offering ) {
|
||||
$this->repository->insert( $offering );
|
||||
|
||||
return $this->reconcileNotice( $offering );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +77,11 @@ class OfferingController {
|
||||
if ( $offeringId > 0 ) {
|
||||
$existing = $this->repository->findById( $offeringId );
|
||||
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
|
||||
$offering = $this->offeringFromPost( $instructorId, $existing );
|
||||
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
|
||||
if ( null !== $offering ) {
|
||||
$this->repository->update( $offeringId, $offering );
|
||||
|
||||
return $this->reconcileNotice( $offering );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +97,97 @@ class OfferingController {
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the assigned instructor's open booking slots that collide with a
|
||||
* scheduled group class and describe the result, warning about any booked
|
||||
* lesson that clashes (which the studio must resolve by hand).
|
||||
*/
|
||||
private function reconcileNotice( Offering $offering ): string {
|
||||
if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $this->reconciler->reconcile( $offering );
|
||||
$parts = [];
|
||||
|
||||
if ( $result['removed'] > 0 ) {
|
||||
$parts[] = sprintf(
|
||||
/* translators: %d: number of open booking slots removed. */
|
||||
_n(
|
||||
'%d open booking slot was removed to hold the class time.',
|
||||
'%d open booking slots were removed to hold the class time.',
|
||||
$result['removed'],
|
||||
'unsupervised-schedular'
|
||||
),
|
||||
$result['removed']
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $result['conflicts'] as $startDt ) {
|
||||
$parts[] = sprintf(
|
||||
/* translators: %s: date and time of the already-booked lesson that clashes. */
|
||||
esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ),
|
||||
(string) mysql2date( 'M j, Y g:i a', $startDt )
|
||||
);
|
||||
}
|
||||
|
||||
return implode( ' ', $parts );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}>
|
||||
*/
|
||||
private function instructorOptions(): array {
|
||||
$roles = [ RoleManager::INSTRUCTOR ];
|
||||
if ( $this->access->adminsAreInstructors() ) {
|
||||
$roles[] = 'administrator';
|
||||
}
|
||||
|
||||
$users = array_filter(
|
||||
get_users(
|
||||
[
|
||||
'role__in' => $roles,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
),
|
||||
static fn( mixed $u ): bool => $u instanceof \WP_User
|
||||
);
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( \WP_User $u ): array => [
|
||||
'id' => (int) $u->ID,
|
||||
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
|
||||
],
|
||||
$users
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an offering from the submitted add/edit form, or null when the
|
||||
* submission is invalid. When `$existing` is given the result is an edit:
|
||||
* it keeps the existing id, owner, and currency so an update can never
|
||||
* reassign an offering to whoever happens to submit the form.
|
||||
* it keeps the existing id and currency so an update can never rewrite those
|
||||
* from whoever submits the form.
|
||||
*
|
||||
* The owning instructor normally stays fixed (the creator on add, the existing
|
||||
* owner on edit). A studio admin (`$manageAll`) may instead assign the class to
|
||||
* any instructor via the picker; a blank or absent choice keeps the default.
|
||||
*/
|
||||
private function offeringFromPost( int $instructorId, ?Offering $existing = null ): ?Offering {
|
||||
private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
|
||||
@@ -96,6 +197,17 @@ class OfferingController {
|
||||
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 ) ) );
|
||||
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
|
||||
$billingMode = Offering::BILLING_ONE_TIME;
|
||||
@@ -104,6 +216,11 @@ class OfferingController {
|
||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
|
||||
|
||||
// A blank cutoff means "use the studio default" (null); any entered value
|
||||
// (including 0 — cancel any time) is a per-offering override.
|
||||
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) );
|
||||
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) );
|
||||
|
||||
// Term dates: a class either meets once (term ends the day it starts)
|
||||
// or repeats weekly for a set number of sessions.
|
||||
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
|
||||
@@ -114,8 +231,19 @@ class OfferingController {
|
||||
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
|
||||
}
|
||||
|
||||
$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(
|
||||
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
|
||||
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
|
||||
kind: $kind,
|
||||
title: $title,
|
||||
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
|
||||
@@ -127,14 +255,39 @@ class OfferingController {
|
||||
capacity: $capacity > 0 ? $capacity : null,
|
||||
termStart: $termStart,
|
||||
termEnd: $termEnd,
|
||||
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'] ?? '' ) ) ) ),
|
||||
classTime: $classTime,
|
||||
enrollmentDeadline: $enrollmentDeadline,
|
||||
withdrawalDeadline: $withdrawalDeadline,
|
||||
scheduleNote: $scheduleNote,
|
||||
etransferEmail: $etransferEmail,
|
||||
cancellationCutoffHours: $cutoffHours,
|
||||
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
||||
isActive: isset( $_POST['is_active'] ),
|
||||
id: $existing?->id,
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
/**
|
||||
* The instructor the offering should belong to. A studio admin may reassign it
|
||||
* via the posted `class_instructor_id`; otherwise it stays with the existing
|
||||
* owner (edit) or the current user (add). A plain instructor can never change
|
||||
* the owner, so the posted value is ignored unless `$manageAll` is set.
|
||||
*/
|
||||
private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int {
|
||||
$fallback = null !== $existing ? $existing->instructorId : $instructorId;
|
||||
|
||||
if ( ! $manageAll ) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) );
|
||||
|
||||
return $posted > 0 ? $posted : $fallback;
|
||||
}
|
||||
|
||||
private function nullableText( string $value ): ?string {
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class OfferingEndpoint {
|
||||
|
||||
public function __construct( private OfferingRepository $repository ) {}
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private GroupAccessRepository $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers this endpoint's REST routes.
|
||||
@@ -62,14 +67,69 @@ class OfferingEndpoint {
|
||||
}
|
||||
|
||||
public function index( \WP_REST_Request $request ): \WP_REST_Response {
|
||||
$offerings = $this->repository->findAll(
|
||||
Val::int( $request->get_param( 'instructor_id' ) ),
|
||||
Val::string( $request->get_param( 'kind' ) ),
|
||||
activeOnly: true,
|
||||
);
|
||||
$instructorId = Val::int( $request->get_param( 'instructor_id' ) );
|
||||
$kind = Val::string( $request->get_param( 'kind' ) );
|
||||
|
||||
// Public listing: omit the private e-transfer destination email.
|
||||
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
|
||||
// The public catalogue is public offerings only; invite-only classes are
|
||||
// hidden from it and surfaced separately to the students granted access.
|
||||
$offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC );
|
||||
|
||||
foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) {
|
||||
$offerings[] = $granted;
|
||||
}
|
||||
|
||||
// Public listing: omit the private e-transfer destination email, and
|
||||
// attach the assigned instructor's display name so the front end can show
|
||||
// students who teaches each class.
|
||||
return new \WP_REST_Response( array_map( [ $this, 'present' ], $offerings ), 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A public-facing offering array with the assigned instructor's name added —
|
||||
* their real name or nickname, never the login (empty when the instructor
|
||||
* account no longer exists). See {@see UserName::format()}.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function present( Offering $offering ): array {
|
||||
$out = $offering->toArray( includeEtransferEmail: false );
|
||||
$user = get_userdata( $offering->instructorId );
|
||||
|
||||
$out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null );
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active invite-only offerings the caller has been granted access to,
|
||||
* matching the same instructor/kind filters as the public catalogue.
|
||||
*
|
||||
* @return list<Offering>
|
||||
*/
|
||||
private function grantedInviteOnly( int $instructorId, string $kind ): array {
|
||||
$grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() );
|
||||
if ( [] === $grantedIds ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $grantedIds as $offeringId ) {
|
||||
$offering = $this->repository->findById( $offeringId );
|
||||
|
||||
if (
|
||||
null === $offering
|
||||
|| ! $offering->isActive
|
||||
|| ! $offering->isInviteOnly()
|
||||
|| ( $instructorId > 0 && $offering->instructorId !== $instructorId )
|
||||
|| ( '' !== $kind && $offering->kind !== $kind )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $offering;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
@@ -88,6 +148,14 @@ class OfferingEndpoint {
|
||||
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(
|
||||
instructorId: get_current_user_id(),
|
||||
kind: $kind,
|
||||
@@ -101,8 +169,11 @@ class OfferingEndpoint {
|
||||
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
|
||||
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
||||
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
||||
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
||||
scheduleNote: $scheduleNote,
|
||||
etransferEmail: $etransferEmail,
|
||||
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
||||
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' ),
|
||||
);
|
||||
|
||||
@@ -133,10 +204,19 @@ class OfferingEndpoint {
|
||||
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(
|
||||
instructorId: $existing->instructorId,
|
||||
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,
|
||||
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
|
||||
billingMode: $billingMode,
|
||||
@@ -146,8 +226,11 @@ class OfferingEndpoint {
|
||||
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity,
|
||||
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
||||
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
||||
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,
|
||||
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
||||
scheduleNote: $scheduleNote,
|
||||
etransferEmail: $etransferEmail,
|
||||
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,
|
||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||
id: $id,
|
||||
);
|
||||
@@ -200,6 +283,34 @@ class OfferingEndpoint {
|
||||
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 {
|
||||
return max( 0.0, Val::float( $value ) );
|
||||
}
|
||||
@@ -210,6 +321,12 @@ class OfferingEndpoint {
|
||||
return '' !== $email ? $email : null;
|
||||
}
|
||||
|
||||
private function accessMode( mixed $value, string $fallback ): string {
|
||||
$mode = Val::string( $value );
|
||||
|
||||
return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback;
|
||||
}
|
||||
|
||||
private function nullableInt( mixed $value ): ?int {
|
||||
return ( null === $value || '' === $value ) ? null : Val::int( $value );
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ class OfferingRepository {
|
||||
/**
|
||||
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
|
||||
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
|
||||
* capacity, term_start, term_end, schedule_note, etransfer_email, is_active).
|
||||
* capacity, term_start, term_end, class_time, enrollment_deadline,
|
||||
* withdrawal_deadline, schedule_note, etransfer_email,
|
||||
* cancellation_cutoff_hours, access_mode, is_active).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%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 {
|
||||
$this->db->insert(
|
||||
@@ -59,18 +61,25 @@ class OfferingRepository {
|
||||
'capacity' => $offering->capacity,
|
||||
'term_start' => $offering->termStart,
|
||||
'term_end' => $offering->termEnd,
|
||||
'class_time' => $offering->classTime,
|
||||
'enrollment_deadline' => $offering->enrollmentDeadline,
|
||||
'withdrawal_deadline' => $offering->withdrawalDeadline,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'etransfer_email' => $offering->etransferEmail,
|
||||
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
|
||||
'access_mode' => $offering->accessMode,
|
||||
'is_active' => $offering->isActive ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find offerings, optionally filtered by instructor, kind, and active state.
|
||||
* Find offerings, optionally filtered by instructor, kind, active state, and
|
||||
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
|
||||
* from the public catalogue).
|
||||
*
|
||||
* @return list<Offering>
|
||||
*/
|
||||
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array {
|
||||
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array {
|
||||
$where = [ '1 = 1' ];
|
||||
$params = [];
|
||||
|
||||
@@ -89,6 +98,11 @@ class OfferingRepository {
|
||||
$params[] = $activeOnly ? 1 : 0;
|
||||
}
|
||||
|
||||
if ( null !== $accessMode ) {
|
||||
$where[] = 'access_mode = %s';
|
||||
$params[] = $accessMode;
|
||||
}
|
||||
|
||||
$whereClause = implode( ' AND ', $where );
|
||||
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ class Payment {
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
public readonly float $taxRate = 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 $stripePaymentIntentId = null,
|
||||
public readonly ?string $receiptNumber = null,
|
||||
@@ -65,6 +69,10 @@ class Payment {
|
||||
status: Val::string( $row->status ),
|
||||
taxRate: Val::float( $row->tax_rate ),
|
||||
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 ),
|
||||
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
|
||||
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
||||
@@ -79,6 +87,15 @@ class Payment {
|
||||
return self::STATUS_PAID === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this payment was generated by the daily billing scan (weekly /
|
||||
* monthly) rather than taken at registration. Scheduled payments carry a due
|
||||
* date, can cover several lessons, and are never auto-voided on cancellation.
|
||||
*/
|
||||
public function isScheduled(): bool {
|
||||
return null !== $this->dueDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount billed including tax.
|
||||
*/
|
||||
@@ -86,6 +103,14 @@ class Payment {
|
||||
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
|
||||
* 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_amount' => $this->taxAmount,
|
||||
'total' => $this->total(),
|
||||
'credit_applied' => $this->creditApplied,
|
||||
'net_due' => $this->netDue(),
|
||||
'currency' => $this->currency,
|
||||
'method' => $this->method,
|
||||
'status' => $this->status,
|
||||
'due_date' => $this->dueDate,
|
||||
'period_key' => $this->periodKey,
|
||||
'notice_batch' => $this->noticeBatch,
|
||||
'receipt_number' => $this->receiptNumber,
|
||||
'paid_at' => $this->paidAt,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -33,22 +33,61 @@ class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
static function ( Payment $payment ): array {
|
||||
$groups = $this->groupPending( $this->payments->findPending() );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group pending payments by their shared notice batch, so payments the daily
|
||||
* scan emailed a student together (and which a single lump-sum e-transfer
|
||||
* covers) are shown as one group with a combined total. Payments with no batch
|
||||
* — legacy at-registration e-transfers — are each their own single-item group.
|
||||
*
|
||||
* @param list<Payment> $pending
|
||||
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
|
||||
*/
|
||||
private function groupPending( array $pending ): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ( $pending as $payment ) {
|
||||
$batch = (string) $payment->noticeBatch;
|
||||
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
|
||||
|
||||
if ( ! isset( $groups[ $key ] ) ) {
|
||||
$groups[ $key ] = [
|
||||
'reference' => $batch,
|
||||
'currency' => $payment->currency,
|
||||
'total_raw' => 0.0,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
return [
|
||||
// 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->amount, 2 ) . ' ' . $payment->currency,
|
||||
'amount' => number_format( $payment->netDue(), 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';
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( array $group ): array => [
|
||||
'reference' => $group['reference'],
|
||||
'is_group' => count( $group['rows'] ) > 1,
|
||||
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
|
||||
'rows' => $group['rows'],
|
||||
],
|
||||
$groups
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ class PaymentRepository {
|
||||
'status' => $payment->status,
|
||||
'tax_rate' => $payment->taxRate,
|
||||
'tax_amount' => $payment->taxAmount,
|
||||
'credit_applied' => $payment->creditApplied,
|
||||
'due_date' => $payment->dueDate,
|
||||
'period_key' => $payment->periodKey,
|
||||
'notice_batch' => $payment->noticeBatch,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
@@ -32,7 +36,7 @@ class PaymentRepository {
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -119,6 +139,51 @@ class PaymentRepository {
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a set of payments with a shared notice-batch reference — the payments the
|
||||
* daily scan emailed a student together, so the admin can see which pending
|
||||
* payments a single lump-sum e-transfer covers. No-op for an empty id list.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function assignNoticeBatch( array $ids, string $batch ): void {
|
||||
if ( [] === $ids ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$sql = $this->db->prepare(
|
||||
"UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )",
|
||||
$this->table,
|
||||
$batch,
|
||||
...$ids
|
||||
);
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled payment already exists for a registration and billing
|
||||
* period. The daily billing scan uses this to avoid double-billing an
|
||||
* enrolment for the same session (weekly) or month (monthly). A voided
|
||||
* (`failed`) row still counts so a cancelled charge is not silently re-created.
|
||||
*/
|
||||
public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1',
|
||||
$this->table,
|
||||
$registrationType,
|
||||
$registrationId,
|
||||
$periodKey
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
|
||||
@@ -21,6 +21,7 @@ class PaymentService {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private StudioSettings $settings,
|
||||
private StripeGateway $stripe,
|
||||
private CreditRepository $credits,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,8 +30,12 @@ class PaymentService {
|
||||
* (card via Stripe — coming soon; e-transfer confirmed manually). The
|
||||
* e-transfer destination is frozen now from the offering override or the studio
|
||||
* default. Returns null when the registration has no price to charge.
|
||||
*
|
||||
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
||||
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
||||
* the pay-now flow.
|
||||
*/
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment {
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
|
||||
if ( $amount <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +63,8 @@ class PaymentService {
|
||||
status: $status,
|
||||
taxRate: $taxRate,
|
||||
taxAmount: $taxAmount,
|
||||
dueDate: $dueDate,
|
||||
periodKey: $periodKey,
|
||||
etransferEmail: $etransferEmail,
|
||||
)
|
||||
);
|
||||
@@ -71,6 +78,26 @@ class PaymentService {
|
||||
return $this->payments->findById( $id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled payment already exists for a registration and billing
|
||||
* period — the daily billing scan's dedup check for group enrolments (whose one
|
||||
* row maps to many periodic charges). Delegates to the ledger.
|
||||
*/
|
||||
public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool {
|
||||
return $this->payments->existsForPeriod( $type, $registrationId, $periodKey );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the payments the daily scan emailed a student together with a shared
|
||||
* notice-batch reference, so a lump-sum e-transfer can be reconciled to the
|
||||
* pending payments it covers. Delegates to the ledger.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function assignNoticeBatch( array $ids, string $batch ): void {
|
||||
$this->payments->assignNoticeBatch( $ids, $batch );
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
||||
* Marks it paid, confirms the registration, and emails the receipt.
|
||||
@@ -92,7 +119,10 @@ class PaymentService {
|
||||
/**
|
||||
* Void the still-pending payment of a cancelled registration so it drops
|
||||
* out of the confirmation queue. Paid payments are left alone — refunds
|
||||
* are a manual, admin-side decision.
|
||||
* are a manual, admin-side decision. Scheduled payments (weekly / monthly)
|
||||
* are also left alone: a monthly charge can cover several lessons and may
|
||||
* already be collected, so cancelling one lesson must never void it or
|
||||
* trigger a rebill.
|
||||
*/
|
||||
public function voidPending( ?int $paymentId ): void {
|
||||
if ( null === $paymentId ) {
|
||||
@@ -100,11 +130,140 @@ class PaymentService {
|
||||
}
|
||||
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
|
||||
if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* For a card payment a Stripe PaymentIntent is created (or replayed
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ class StudioSettings {
|
||||
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
|
||||
public const OPT_HST_RATE = 'us_hst_rate';
|
||||
|
||||
/**
|
||||
* Studio-default cancellation cutoff, stored in hours. A student may not
|
||||
* cancel a lesson once it starts within this many hours. Displayed to the
|
||||
* admin in days; an offering may override it with its own hour value.
|
||||
*/
|
||||
public const OPT_CANCELLATION_CUTOFF_HOURS = 'us_cancellation_cutoff_hours';
|
||||
public const DEFAULT_CANCELLATION_CUTOFF_HOURS = 24;
|
||||
|
||||
public const OPT_REGISTRATION_MODE = 'us_registration_mode';
|
||||
public const MODE_INVITE = 'invite';
|
||||
public const MODE_SELF_APPROVAL = 'self_approval';
|
||||
@@ -69,6 +77,15 @@ class StudioSettings {
|
||||
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio-default cancellation cutoff in hours: a student cannot cancel a
|
||||
* lesson once it starts within this window. 0 means students may cancel any
|
||||
* time. Offerings without their own override inherit this value.
|
||||
*/
|
||||
public function cancellationCutoffHours(): int {
|
||||
return max( 0, Val::int( get_option( self::OPT_CANCELLATION_CUTOFF_HOURS, self::DEFAULT_CANCELLATION_CUTOFF_HOURS ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Stripe is configured. When false the platform falls back to
|
||||
* e-transfer billing and card processing is unavailable.
|
||||
@@ -117,6 +134,8 @@ class StudioSettings {
|
||||
$hstRate = $this->hstRate();
|
||||
$stripeConfigured = $this->isStripeConfigured();
|
||||
$openRegistration = $this->openRegistrationEnabled();
|
||||
// Stored in hours, surfaced to the admin in whole days.
|
||||
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
|
||||
}
|
||||
@@ -143,6 +162,11 @@ class StudioSettings {
|
||||
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
|
||||
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
|
||||
|
||||
// The cutoff is entered in whole days but stored in hours.
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::int() coerces to int; slashes cannot survive numeric coercion.
|
||||
$cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0, Val::int( $_POST['cancellation_cutoff_days'] ) ) : 0;
|
||||
update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, $cutoffDays * 24 );
|
||||
|
||||
$this->applyRegistrationMode( isset( $_POST['open_registration'] ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
+24
-4
@@ -10,16 +10,21 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\StudentAdminGuard;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\ReceiptMailer;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Payment\StripeGateway;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
@@ -51,6 +56,16 @@ class Plugin {
|
||||
$bookings = new BookingRepository( $wpdb );
|
||||
$offerings = new OfferingRepository( $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 );
|
||||
$policies = new PolicyRepository( $wpdb );
|
||||
$policyVersions = new PolicyVersionRepository( $wpdb );
|
||||
@@ -58,13 +73,15 @@ class Plugin {
|
||||
$acceptances = new AcceptanceRepository( $wpdb );
|
||||
$invites = new InviteRepository( $wpdb );
|
||||
$enrollments = new EnrollmentRepository( $wpdb );
|
||||
$groupAccess = new GroupAccessRepository( $wpdb );
|
||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||
|
||||
$paymentRepo = new PaymentRepository( $wpdb );
|
||||
$creditRepo = new CreditRepository( $wpdb );
|
||||
$settings = new StudioSettings();
|
||||
$resolver = new BillingMethodResolver( $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
|
||||
// front-end output is identical whichever way a page embeds them.
|
||||
@@ -72,15 +89,18 @@ class Plugin {
|
||||
|
||||
$bookingPage = new BookingPage();
|
||||
$loginPage = new LoginPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer );
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->register();
|
||||
( new StudentAdminGuard() )->register();
|
||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->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 ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ class Policy {
|
||||
*/
|
||||
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 readonly string $title,
|
||||
public readonly string $slug,
|
||||
|
||||
@@ -47,7 +47,9 @@ class PolicyController {
|
||||
$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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -118,12 +118,30 @@ class PolicyEndpoint {
|
||||
if ( '' === $title ) {
|
||||
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' ) ) );
|
||||
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
|
||||
if ( '' === $slug ) {
|
||||
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 ) ) {
|
||||
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
|
||||
@@ -9,13 +9,15 @@ class Answer {
|
||||
|
||||
public const REG_LESSON = 'lesson';
|
||||
public const REG_ENROLLMENT = 'enrollment';
|
||||
public const REG_ACCOUNT = 'account';
|
||||
|
||||
/**
|
||||
* Polymorphic registration targets an answer can attach to.
|
||||
* Polymorphic registration targets an answer can attach to. `account` is used
|
||||
* by studio-wide questions answered at signup (registration_id = the user ID).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ];
|
||||
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $questionId,
|
||||
|
||||
@@ -12,6 +12,15 @@ class Question {
|
||||
public const FIELD_SELECT = 'select';
|
||||
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. */
|
||||
public const SCOPE_OFFERING = 'offering';
|
||||
|
||||
/** Question is studio-wide, asked once at account signup (no offering). */
|
||||
public const SCOPE_ACCOUNT = 'account';
|
||||
|
||||
/**
|
||||
* All valid field types.
|
||||
*
|
||||
@@ -24,19 +33,31 @@ class Question {
|
||||
self::FIELD_CHECKBOX,
|
||||
];
|
||||
|
||||
/**
|
||||
* All valid scopes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_SCOPES = [
|
||||
self::SCOPE_OFFERING,
|
||||
self::SCOPE_ACCOUNT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build an intake question value object.
|
||||
*
|
||||
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
|
||||
* @param list<string>|null $options Choices for a `select` field.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $offeringId,
|
||||
public readonly ?int $offeringId,
|
||||
public readonly string $label,
|
||||
public readonly string $fieldType = self::FIELD_TEXT,
|
||||
public readonly ?array $options = null,
|
||||
public readonly bool $isRequired = false,
|
||||
public readonly int $sortOrder = 0,
|
||||
public readonly bool $isActive = true,
|
||||
public readonly string $scope = self::SCOPE_OFFERING,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -50,13 +71,14 @@ class Question {
|
||||
}
|
||||
|
||||
return new self(
|
||||
offeringId: Val::int( $row->offering_id ),
|
||||
offeringId: Val::intOrNull( $row->offering_id ),
|
||||
label: Val::string( $row->label ),
|
||||
fieldType: Val::string( $row->field_type ),
|
||||
options: $options,
|
||||
isRequired: Val::bool( $row->is_required ),
|
||||
sortOrder: Val::int( $row->sort_order ),
|
||||
isActive: Val::bool( $row->is_active ),
|
||||
scope: Val::string( $row->scope ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -70,6 +92,7 @@ class Question {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'offering_id' => $this->offeringId,
|
||||
'scope' => $this->scope,
|
||||
'label' => $this->label,
|
||||
'field_type' => $this->fieldType,
|
||||
'options' => $this->options,
|
||||
|
||||
@@ -23,8 +23,13 @@ class QuestionController {
|
||||
$userId = get_current_user_id();
|
||||
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
|
||||
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
|
||||
// The selector posts either an offering id or the sentinel `account`.
|
||||
// Account-signup questions are studio-wide, so only studio admins manage them.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only selector.
|
||||
$selection = sanitize_text_field( Val::string( wp_unslash( $_GET['offering_id'] ?? '' ) ) );
|
||||
$accountScope = $manageAll && Question::SCOPE_ACCOUNT === $selection;
|
||||
|
||||
$offeringId = $accountScope ? 0 : absint( Val::int( $selection ) );
|
||||
$offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
|
||||
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
|
||||
|
||||
@@ -33,7 +38,13 @@ class QuestionController {
|
||||
}
|
||||
|
||||
$questions = null;
|
||||
if ( null !== $selectedOffering ) {
|
||||
if ( $accountScope ) {
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
|
||||
$this->handleFormAction( null );
|
||||
}
|
||||
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT );
|
||||
} elseif ( null !== $selectedOffering ) {
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
|
||||
$this->handleFormAction( $selectedOffering );
|
||||
}
|
||||
@@ -44,20 +55,24 @@ class QuestionController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/questions.php';
|
||||
}
|
||||
|
||||
private function handleFormAction( Offering $offering ): void {
|
||||
/**
|
||||
* Handle an add/delete action for the given context: an offering, or account
|
||||
* scope when $offering is null.
|
||||
*/
|
||||
private function handleFormAction( ?Offering $offering ): void {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
|
||||
if ( 'add' === $action ) {
|
||||
$this->addQuestion( (int) $offering->id );
|
||||
$this->addQuestion( $offering );
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
|
||||
if ( $questionId > 0 ) {
|
||||
$question = $this->questions->findById( $questionId );
|
||||
if ( $question && $question->offeringId === (int) $offering->id ) {
|
||||
if ( $question && $this->belongsToContext( $question, $offering ) ) {
|
||||
$this->questions->delete( $questionId );
|
||||
}
|
||||
}
|
||||
@@ -65,28 +80,41 @@ class QuestionController {
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
private function addQuestion( int $offeringId ): void {
|
||||
private function addQuestion( ?Offering $offering ): void {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
|
||||
$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;
|
||||
}
|
||||
|
||||
$this->questions->insert(
|
||||
new Question(
|
||||
offeringId: $offeringId,
|
||||
offeringId: null === $offering ? null : (int) $offering->id,
|
||||
label: $label,
|
||||
fieldType: $fieldType,
|
||||
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
|
||||
isRequired: isset( $_POST['is_required'] ),
|
||||
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
|
||||
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a question belongs to the current editing context — the given
|
||||
* offering, or account scope when $offering is null.
|
||||
*/
|
||||
private function belongsToContext( Question $question, ?Offering $offering ): bool {
|
||||
if ( null === $offering ) {
|
||||
return Question::SCOPE_ACCOUNT === $question->scope;
|
||||
}
|
||||
|
||||
return $question->offeringId === (int) $offering->id;
|
||||
}
|
||||
|
||||
private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool {
|
||||
return $manageAll || $offering->instructorId === $userId;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ class QuestionEndpoint {
|
||||
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 ) );
|
||||
}
|
||||
|
||||
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
||||
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
||||
@@ -118,14 +121,23 @@ class QuestionEndpoint {
|
||||
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(
|
||||
offeringId: $existing->offeringId,
|
||||
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
|
||||
label: $label,
|
||||
fieldType: $fieldType,
|
||||
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,
|
||||
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
|
||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||
scope: $existing->scope,
|
||||
id: $id,
|
||||
);
|
||||
|
||||
@@ -167,8 +179,14 @@ class QuestionEndpoint {
|
||||
|
||||
/**
|
||||
* Ensure the offering exists and the caller owns it (or is a studio admin).
|
||||
* Account-scoped questions have no offering and are not managed over REST, so
|
||||
* a null offering id is rejected as not found.
|
||||
*/
|
||||
private function requireOfferingOwner( int $offeringId ): ?\WP_Error {
|
||||
private function requireOfferingOwner( ?int $offeringId ): ?\WP_Error {
|
||||
if ( null === $offeringId ) {
|
||||
return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
|
||||
if ( null === $offering ) {
|
||||
@@ -210,4 +228,16 @@ class QuestionEndpoint {
|
||||
private function invalid( string $message ): \WP_Error {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class QuestionRepository {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
||||
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -26,7 +26,7 @@ class QuestionRepository {
|
||||
$this->table,
|
||||
$this->columns( $question ),
|
||||
[ 'id' => $id ],
|
||||
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ],
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class QuestionRepository {
|
||||
private function columns( Question $question ): array {
|
||||
return [
|
||||
'offering_id' => $question->offeringId,
|
||||
'scope' => $question->scope,
|
||||
'label' => $question->label,
|
||||
'field_type' => $question->fieldType,
|
||||
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
|
||||
@@ -69,6 +70,27 @@ class QuestionRepository {
|
||||
return array_map( Question::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find questions for a scope (e.g. account-signup), ordered for display.
|
||||
*
|
||||
* @return list<Question>
|
||||
*/
|
||||
public function findByScope( string $scope, bool $activeOnly = false ): array {
|
||||
$sql = 'SELECT * FROM %i WHERE scope = %s';
|
||||
$params = [ $this->table, $scope ];
|
||||
|
||||
if ( $activeOnly ) {
|
||||
$sql .= ' AND is_active = %d';
|
||||
$params[] = 1;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY sort_order ASC, id ASC';
|
||||
|
||||
$rows = $this->db->get_results( $this->db->prepare( $sql, $params ) );
|
||||
|
||||
return array_map( Question::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Question {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||||
@@ -84,4 +106,26 @@ class QuestionRepository {
|
||||
[ '%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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
@@ -33,13 +36,13 @@ class RestRegistrar {
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
private PaymentEndpoint $paymentEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) {
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
}
|
||||
|
||||
|
||||
+47
-1
@@ -63,8 +63,13 @@ class Schema {
|
||||
capacity SMALLINT UNSIGNED DEFAULT NULL,
|
||||
term_start DATE DEFAULT NULL,
|
||||
term_end DATE DEFAULT NULL,
|
||||
class_time TIME DEFAULT NULL,
|
||||
enrollment_deadline DATE DEFAULT NULL,
|
||||
withdrawal_deadline DATE DEFAULT NULL,
|
||||
schedule_note VARCHAR(191) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) DEFAULT NULL,
|
||||
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
|
||||
access_mode VARCHAR(20) NOT NULL DEFAULT 'public',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
@@ -75,7 +80,8 @@ class Schema {
|
||||
|
||||
"CREATE TABLE {$prefix}us_questions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
offering_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'offering',
|
||||
label VARCHAR(255) NOT NULL,
|
||||
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
options TEXT,
|
||||
@@ -85,6 +91,7 @@ class Schema {
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY offering_id (offering_id),
|
||||
KEY scope (scope),
|
||||
KEY is_active (is_active)
|
||||
) {$charset};",
|
||||
|
||||
@@ -153,6 +160,10 @@ class Schema {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
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,
|
||||
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
receipt_number VARCHAR(50) DEFAULT NULL,
|
||||
@@ -166,6 +177,24 @@ class Schema {
|
||||
KEY status (status)
|
||||
) {$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 (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
@@ -187,6 +216,7 @@ class Schema {
|
||||
token VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
|
||||
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
|
||||
offering_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
invited_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
@@ -198,6 +228,22 @@ class Schema {
|
||||
KEY email (email),
|
||||
KEY status (status)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_group_access (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
email VARCHAR(191) NOT NULL DEFAULT '',
|
||||
invite_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'invited',
|
||||
invited_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY offering_id (offering_id),
|
||||
KEY student_id (student_id),
|
||||
KEY email (email),
|
||||
KEY status (status)
|
||||
) {$charset};",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ class ShortcodeRegistrar {
|
||||
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, '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( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||
}
|
||||
@@ -69,5 +72,8 @@ class ShortcodeRegistrar {
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,58 @@ class UpdateChecker {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
|
||||
add_filter( 'plugin_row_meta', [ $this, 'filterRowMeta' ], 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_plugins_{hostname}` filter callback. Returns the incoming
|
||||
* value untouched unless a newer release with a zip asset exists, in
|
||||
* which case it returns the update array core expects.
|
||||
* Replace core's "View details" link on the Plugins screen.
|
||||
*
|
||||
* Core points that link at the WordPress.org plugin-information API
|
||||
* (`plugin-install.php?tab=plugin-information&plugin=…`), which 404s in a
|
||||
* "Plugin not found" iframe for this off-directory plugin. We swap it for a
|
||||
* direct link to the matching Gitea release tag page, opened in a new tab —
|
||||
* embedding Gitea in the thickbox iframe would be blocked by its
|
||||
* `X-Frame-Options: SAMEORIGIN` anyway.
|
||||
*
|
||||
* @param mixed $meta Row-meta links for the plugin.
|
||||
* @param mixed $plugin_file Plugin file the meta belongs to.
|
||||
* @return mixed The (possibly modified) row-meta array.
|
||||
*/
|
||||
public function filterRowMeta( mixed $meta, mixed $plugin_file ): mixed {
|
||||
if ( ! is_array( $meta ) || plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
// Drop core's WordPress.org "View details" thickbox link.
|
||||
$meta = array_values(
|
||||
array_filter(
|
||||
$meta,
|
||||
static fn( $item ): bool => ! ( is_string( $item ) && str_contains( $item, 'open-plugin-details-modal' ) )
|
||||
)
|
||||
);
|
||||
|
||||
$meta[] = sprintf(
|
||||
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
|
||||
esc_url( self::REPO_URL . '/releases/tag/v' . USC_VERSION ),
|
||||
esc_html__( 'View details', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_plugins_{hostname}` filter callback.
|
||||
*
|
||||
* For a newer release with a zip asset, returns the update array core
|
||||
* files under the transient's `response` list (the update offer).
|
||||
* Otherwise — the plugin is current, or the release lookup failed — it
|
||||
* returns a payload with the installed version and no package, which core
|
||||
* files under `no_update`. That `no_update` entry is what sets core's
|
||||
* `update-supported` flag and makes the "Enable auto-updates" toggle
|
||||
* appear on the Plugins screen; without it, an off-directory plugin is
|
||||
* absent from the transient between releases and the toggle never shows.
|
||||
*
|
||||
* The incoming value is only passed through untouched for other plugins.
|
||||
*/
|
||||
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
|
||||
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
@@ -49,14 +95,8 @@ class UpdateChecker {
|
||||
|
||||
$release = $this->latestRelease();
|
||||
|
||||
if ( '' === $release['version'] || '' === $release['package'] ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
if ( '' !== $release['version'] && '' !== $release['package']
|
||||
&& version_compare( $release['version'], USC_VERSION, '>' ) ) {
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => $release['version'],
|
||||
@@ -65,6 +105,17 @@ class UpdateChecker {
|
||||
];
|
||||
}
|
||||
|
||||
// No newer release: answer with a `no_update` payload so core keeps
|
||||
// the plugin in the update transient and shows the auto-update toggle.
|
||||
// The empty package leaves core nothing to auto-install, as intended.
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => USC_VERSION,
|
||||
'url' => self::REPO_URL,
|
||||
'package' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest published release, from the transient cache when fresh.
|
||||
*
|
||||
|
||||
@@ -5,29 +5,56 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{student: string, offering: string, status: string}> $rows */
|
||||
/**
|
||||
* @var list<array{id: int|null, title: string, instructor: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $rows
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Group Classes', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Active enrolments across all group classes.', 'unsupervised-schedular'); ?></p>
|
||||
<p class="description"><?php esc_html_e('Every group class across instructors. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($rows)) : ?>
|
||||
<p><?php esc_html_e('No active enrolments.', 'unsupervised-schedular'); ?></p>
|
||||
<p><?php esc_html_e('No group classes.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['offering']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
<td>
|
||||
<?php echo esc_html($row['title']); ?>
|
||||
<?php if ($row['invite_only']) : ?>
|
||||
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
<td><?php echo '' !== $row['when'] ? esc_html($row['when']) : '—'; ?></td>
|
||||
<td>
|
||||
<?php
|
||||
if (null === $row['capacity']) {
|
||||
echo esc_html((string) $row['enrolled']);
|
||||
} else {
|
||||
echo esc_html($row['enrolled'] . ' / ' . $row['capacity']);
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $row['id'], $baseUrl)); ?>"><?php echo $row['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
|
||||
* @var list<array{question: string, answer: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
|
||||
* @var string $backUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lesson details', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<p><a href="<?php echo esc_url($backUrl); ?>">« <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['time']); ?>
|
||||
<?php if ('weekly' === $row['recurrence']) : ?>
|
||||
<em>(<?php esc_html_e('weekly', 'unsupervised-schedular'); ?>)</em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<?php if ($row['payment_id'] > 0) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['currency'] . ' ' . number_format($row['total'], 2)); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if ('' !== $row['notes']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($accepts)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accepts as $acceptance) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($answers)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($answers as $answer) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -6,10 +6,10 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var list<array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var 'list'|'week' $view
|
||||
* @var string $weekStart
|
||||
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var list<array{date: string, items: list<array{lesson_id: int, student: string, offering: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $baseUrl
|
||||
@@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {
|
||||
<p style="margin:0 0 8px;">
|
||||
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
|
||||
<?php echo esc_html($item['student']); ?><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em>
|
||||
<span><?php echo esc_html($item['offering']); ?></span><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em><br>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $item['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></a>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Details', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -87,6 +91,17 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<span style="color:#666;">
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['time']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
<td>
|
||||
@@ -118,6 +133,9 @@ if (! defined('ABSPATH')) {
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $row['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
|
||||
* @var list<array{id: int, name: string}> $students
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1>
|
||||
<?php echo esc_html($class['title']); ?>
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<a href="<?php echo esc_url($baseUrl); ?>">← <?php esc_html_e('Back to my group classes', 'unsupervised-schedular'); ?></a>
|
||||
</p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Class details', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="widefat striped" style="max-width:40em;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '—'; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php
|
||||
if (null === $class['capacity']) {
|
||||
printf(
|
||||
/* translators: %d: number of enrolled students. */
|
||||
esc_html__('%d enrolled', 'unsupervised-schedular'),
|
||||
(int) $class['enrolled']
|
||||
);
|
||||
} else {
|
||||
printf(
|
||||
/* translators: 1: number of enrolled students, 2: class capacity. */
|
||||
esc_html__('%1$d / %2$d enrolled', 'unsupervised-schedular'),
|
||||
(int) $class['enrolled'],
|
||||
(int) $class['capacity']
|
||||
);
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if (null !== $class['duration']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: session length in minutes. */
|
||||
esc_html__('%d min', 'unsupervised-schedular'),
|
||||
(int) $class['duration']
|
||||
);
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
|
||||
</tr>
|
||||
<?php if ('' !== $class['deadline']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['deadline']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['schedule_note']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (null !== $class['description'] && '' !== $class['description']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['description']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php if ($class['active']) : ?>
|
||||
<?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e('Closed', 'unsupervised-schedular'); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
· <?php esc_html_e('Invite only', 'unsupervised-schedular'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Enrolled students', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($class['roster'])) : ?>
|
||||
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($class['roster'] as $entry) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($entry['student']); ?></td>
|
||||
<td><?php echo esc_html($entry['status']); ?></td>
|
||||
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>
|
||||
<?php
|
||||
echo $class['invite_only']
|
||||
? esc_html__('Invite & enrol students', 'unsupervised-schedular')
|
||||
: esc_html__('Add students', 'unsupervised-schedular');
|
||||
?>
|
||||
</h2>
|
||||
<?php if (! $class['enrollment_open']) : ?>
|
||||
<p class="description"><?php esc_html_e('Enrolment has closed for this class. Students you add here are enrolled as late enrolments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif ($class['invite_only']) : ?>
|
||||
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($class['invite_only'] && ! empty($class['invited'])) : ?>
|
||||
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
|
||||
<ul class="ul-disc">
|
||||
<?php foreach ($class['invited'] as $invitee) : ?>
|
||||
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description">
|
||||
<?php
|
||||
echo $class['enrollment_open']
|
||||
? esc_html__('Enrols them now with a pending payment.', 'unsupervised-schedular')
|
||||
: esc_html__('Enrols them now with a pending payment, past the enrolment deadline.', 'unsupervised-schedular');
|
||||
?>
|
||||
</p>
|
||||
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
|
||||
<?php foreach ($students as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Make available to students', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description"><?php esc_html_e('Grants access so they can enrol themselves.', '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="grant_access" class="button"><?php esc_html_e('Grant access', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<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('Invite by email', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description"><?php esc_html_e('For someone without an account yet.', 'unsupervised-schedular'); ?></p>
|
||||
<input type="email" name="email" class="regular-text" placeholder="<?php esc_attr_e('[email protected]', 'unsupervised-schedular'); ?>">
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $classes
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Your group classes. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($classes)) : ?>
|
||||
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classes as $class) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php echo esc_html($class['title']); ?>
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '—'; ?></td>
|
||||
<td>
|
||||
<?php
|
||||
if (null === $class['capacity']) {
|
||||
echo esc_html((string) $class['enrolled']);
|
||||
} else {
|
||||
echo esc_html($class['enrolled'] . ' / ' . $class['capacity']);
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $class['id'], $baseUrl)); ?>"><?php echo $class['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
+101
-13
@@ -10,6 +10,9 @@ if (! defined('ABSPATH')) {
|
||||
/**
|
||||
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
|
||||
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
|
||||
* @var list<array{id: int, name: string}> $instructors Instructors offered in the assignment picker (studio admins only).
|
||||
* @var bool $manageAll Whether the current user may assign classes to other instructors.
|
||||
* @var string $notice Status message from the last save (slot-clearing / conflicts).
|
||||
*/
|
||||
|
||||
$baseUrl = admin_url('admin.php?page=us-offerings');
|
||||
@@ -26,6 +29,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_offering_action'); ?>
|
||||
@@ -38,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<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>
|
||||
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
||||
@@ -49,6 +56,19 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($manageAll) : ?>
|
||||
<tr>
|
||||
<th><label for="class_instructor_id"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="class_instructor_id" id="class_instructor_id">
|
||||
<?php foreach ($instructors as $instructor) : ?>
|
||||
<option value="<?php echo esc_attr((string) $instructor['id']); ?>" <?php echo $editing && $editing->instructorId === $instructor['id'] ? 'selected' : ''; ?>><?php echo esc_html($instructor['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('Who teaches this. Assigning a group class clears that instructor’s open booking slots at the class time.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr>
|
||||
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
|
||||
@@ -65,27 +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>
|
||||
<td>
|
||||
<select name="billing_mode" id="billing_mode">
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>" <?php echo $editing && Offering::BILLING_ONE_TIME === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_WEEKLY); ?>" <?php echo $editing && Offering::BILLING_WEEKLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Weekly — due 24h before each lesson', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_MONTHLY); ?>" <?php echo $editing && Offering::BILLING_MONTHLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Monthly — billed on the 1st for that month\'s lessons', 'unsupervised-schedular'); ?></option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="us-private-only">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="us-group-only">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="us-group-only">
|
||||
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="us-group-only">
|
||||
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
|
||||
<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)); ?>">
|
||||
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructor’s availability.', 'unsupervised-schedular'); ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr 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>
|
||||
<td>
|
||||
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
||||
@@ -95,13 +138,27 @@ 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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="us-group-only">
|
||||
<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>
|
||||
<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>
|
||||
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="number" name="cancellation_cutoff_hours" id="cancellation_cutoff_hours" min="0" step="1" placeholder="<?php esc_attr_e('Uses the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr(null === ($editing->cancellationCutoffHours ?? null) ? '' : (string) $editing->cancellationCutoffHours); ?>">
|
||||
<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>
|
||||
</tr>
|
||||
<tr class="us-group-only">
|
||||
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
|
||||
<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>
|
||||
<p class="description"><?php esc_html_e('Invite-only classes never appear in the student catalog. Add or invite students from My Lessons → My Group Classes.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
||||
@@ -114,6 +171,25 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
|
||||
<?php if (empty($offerings)) : ?>
|
||||
@@ -137,7 +213,12 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<?php foreach ($offerings as $offering) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html((string) $offering->id); ?></td>
|
||||
<td><?php echo esc_html($offering->title); ?></td>
|
||||
<td>
|
||||
<?php echo esc_html($offering->title); ?>
|
||||
<?php if ($offering->isInviteOnly()) : ?>
|
||||
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($offering->kind); ?></td>
|
||||
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></td>
|
||||
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
|
||||
@@ -145,10 +226,17 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<td>
|
||||
<?php if (null === $offering->termStart) : ?>
|
||||
—
|
||||
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
|
||||
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
|
||||
<?php else : ?>
|
||||
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' – ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
|
||||
<?php
|
||||
if (null === $offering->termEnd || $offering->termEnd === $offering->termStart) {
|
||||
echo esc_html((string) mysql2date('M j, Y', $offering->termStart));
|
||||
} else {
|
||||
echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' – ' . (string) mysql2date('M j, Y', $offering->termEnd));
|
||||
}
|
||||
if (null !== $offering->classTime) {
|
||||
echo '<br><span class="description">' . esc_html((string) mysql2date('g:i a', $offering->termStart . ' ' . $offering->classTime)) . '</span>';
|
||||
}
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
||||
|
||||
@@ -5,13 +5,13 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}> $rows */
|
||||
/** @var list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}> $groups */
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere.', 'unsupervised-schedular'); ?></p>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere. Payments billed together on one notice are grouped under a reference — a single lump-sum e-transfer covers every payment in the group.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if (empty($rows)) : ?>
|
||||
<?php if (empty($groups)) : ?>
|
||||
<p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
@@ -26,7 +26,23 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) : ?>
|
||||
<?php foreach ($groups as $group) : ?>
|
||||
<?php if ($group['is_group']) : ?>
|
||||
<tr>
|
||||
<td colspan="6" style="background:#f0f6fc;">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: notice reference code, 2: lump-sum total, 3: number of payments. */
|
||||
esc_html__('Grouped notice %1$s — one lump-sum e-transfer of %2$s covers the %3$d payments below.', 'unsupervised-schedular'),
|
||||
'<strong>' . esc_html($group['reference']) . '</strong>',
|
||||
'<strong>' . esc_html($group['total']) . '</strong>',
|
||||
count($group['rows'])
|
||||
);
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($group['rows'] as $row) : ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_payment_action'); ?>
|
||||
@@ -45,6 +61,7 @@ if (! defined('ABSPATH')) {
|
||||
</form>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -24,12 +24,12 @@ if (! defined('ABSPATH')) {
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<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>
|
||||
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -10,6 +10,8 @@ if (! defined('ABSPATH')) {
|
||||
/**
|
||||
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringList
|
||||
* @var \Unsupervised\Schedular\Offering\Offering|null $selectedOffering
|
||||
* @var bool $accountScope Whether the studio-wide account-signup questions are selected.
|
||||
* @var bool $manageAll Whether the current user is a studio admin (may edit account questions).
|
||||
* @var list<\Unsupervised\Schedular\Registration\Question>|null $questions
|
||||
*/
|
||||
?>
|
||||
@@ -18,21 +20,31 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<form method="get">
|
||||
<input type="hidden" name="page" value="us-questions">
|
||||
<label for="offering_id"><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></label>
|
||||
<label for="offering_id"><?php esc_html_e('Questions for', 'unsupervised-schedular'); ?></label>
|
||||
<select name="offering_id" id="offering_id" onchange="this.form.submit()">
|
||||
<option value="0"><?php esc_html_e('— Select an offering —', 'unsupervised-schedular'); ?></option>
|
||||
<option value="0"><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
|
||||
<?php if ($manageAll) : ?>
|
||||
<option value="<?php echo esc_attr(Question::SCOPE_ACCOUNT); ?>" <?php selected($accountScope); ?>>
|
||||
<?php esc_html_e('Account signup (all registrations)', 'unsupervised-schedular'); ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($offeringList as $offering) : ?>
|
||||
<option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected($selectedOffering && $selectedOffering->id === $offering->id); ?>>
|
||||
<option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected(! $accountScope && $selectedOffering && $selectedOffering->id === $offering->id); ?>>
|
||||
<?php echo esc_html($offering->title); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<?php if (null === $selectedOffering) : ?>
|
||||
<p><?php esc_html_e('Choose an offering to manage its intake questions.', 'unsupervised-schedular'); ?></p>
|
||||
<?php if (! $accountScope && null === $selectedOffering) : ?>
|
||||
<p><?php esc_html_e('Choose an offering to manage its intake questions, or "Account signup" for the questions every new student answers when registering.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<?php if ($accountScope) : ?>
|
||||
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
|
||||
<p><?php esc_html_e('Every new student answers these required-if-marked questions as a second step after choosing their name and password.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e('Add Question', 'unsupervised-schedular'); ?></h3>
|
||||
<form method="post">
|
||||
@@ -41,7 +53,7 @@ if (! defined('ABSPATH')) {
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<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>
|
||||
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
|
||||
@@ -74,7 +86,7 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<h3><?php esc_html_e('Current Questions', 'unsupervised-schedular'); ?></h3>
|
||||
<?php if (empty($questions)) : ?>
|
||||
<p><?php esc_html_e('No questions configured for this offering.', 'unsupervised-schedular'); ?></p>
|
||||
<p><?php esc_html_e('No questions configured yet.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
|
||||
@@ -16,6 +16,7 @@ if (! defined('ABSPATH')) {
|
||||
* @var float $hstRate
|
||||
* @var bool $stripeConfigured
|
||||
* @var bool $openRegistration
|
||||
* @var int $cancellationCutoffDays
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -90,6 +91,17 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Cancellations', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th><label for="cancellation_cutoff_days"><?php esc_html_e('Cancellation cutoff (days)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="number" name="cancellation_cutoff_days" id="cancellation_cutoff_days" class="small-text" min="0" step="1" value="<?php echo esc_attr((string) $cancellationCutoffDays); ?>">
|
||||
<p class="description"><?php esc_html_e('How far ahead of a lesson a student may still cancel it. Within this window only an instructor can cancel. 0 lets students cancel any time. An offering can override this with its own value.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
|
||||
@@ -11,8 +11,12 @@ if (! defined('ABSPATH')) {
|
||||
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
||||
* @var list<array{id: int, offering: string, status: string}> $enrolments
|
||||
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
|
||||
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo
|
||||
* @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, amount: float, remaining: float, currency: string, reason: string, status: string}> $credits
|
||||
* @var float $creditBalance
|
||||
* @var string $creditCurrency
|
||||
* @var string $backUrl
|
||||
* @var bool $canBilling
|
||||
* @var string $billingOverride
|
||||
@@ -101,6 +105,33 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
||||
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
|
||||
</form>
|
||||
|
||||
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($registrationInfo)) : ?>
|
||||
<p><?php esc_html_e('No registration questions are configured.', '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 ($registrationInfo as $row) : ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php echo esc_html($row['question']); ?>
|
||||
<?php if ($row['required']) : ?>
|
||||
<span class="us-required" aria-hidden="true">*</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['answer']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canBilling) : ?>
|
||||
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
|
||||
<form method="post">
|
||||
@@ -213,6 +244,42 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
||||
<?php endif; ?>
|
||||
|
||||
<?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>
|
||||
<?php if (empty($payments)) : ?>
|
||||
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
@@ -10,13 +12,49 @@ if (! defined('ABSPATH')) {
|
||||
* @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 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 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 $loginUrl Where the post-confirmation sign-in link points.
|
||||
* @var string $error
|
||||
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render one account-signup question's input, named `us_answers[<id>]`.
|
||||
*/
|
||||
$renderQuestionField = static function (Question $question): void {
|
||||
$id = (int) $question->id;
|
||||
$name = 'us_answers[' . $id . ']';
|
||||
$fieldId = 'us-reg-q-' . $id;
|
||||
$required = $question->isRequired ? ' required' : '';
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($fieldId); ?>">
|
||||
<?php echo esc_html($question->label); ?>
|
||||
<?php if ($question->isRequired) : ?>
|
||||
<span class="us-required" aria-hidden="true">*</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<?php if ($question->fieldType === Question::FIELD_TEXTAREA) : ?>
|
||||
<textarea name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" rows="4"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>></textarea>
|
||||
<?php elseif ($question->fieldType === Question::FIELD_SELECT) : ?>
|
||||
<select name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
|
||||
<option value=""><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ((array) $question->options as $option) : ?>
|
||||
<option value="<?php echo esc_attr((string) $option); ?>"><?php echo esc_html((string) $option); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php elseif ($question->fieldType === Question::FIELD_CHECKBOX) : ?>
|
||||
<input type="checkbox" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" value="1"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
|
||||
<?php else : ?>
|
||||
<input type="text" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php
|
||||
};
|
||||
?>
|
||||
<div class="us-register-form">
|
||||
<?php if ($successType === 'invite') : ?>
|
||||
@@ -37,16 +75,18 @@ if (! defined('ABSPATH')) {
|
||||
<?php endif; ?>
|
||||
|
||||
<?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 if ($error !== '') : ?>
|
||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="">
|
||||
<?php $hasQuestions = ! empty($accountQuestions); ?>
|
||||
<form method="post" action="" <?php echo $hasQuestions ? 'data-steps="1"' : ''; ?>>
|
||||
<?php wp_nonce_field('us_student_register'); ?>
|
||||
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
|
||||
|
||||
<div class="us-reg-step" data-step="1">
|
||||
<p>
|
||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
|
||||
@@ -83,9 +123,31 @@ if (! defined('ABSPATH')) {
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($hasQuestions) : ?>
|
||||
<p>
|
||||
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p>
|
||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($hasQuestions) : ?>
|
||||
<div class="us-reg-step" data-step="2">
|
||||
<fieldset class="us-reg-questions">
|
||||
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
|
||||
<?php foreach ($accountQuestions as $question) : ?>
|
||||
<?php $renderQuestionField($question); ?>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
<p>
|
||||
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
|
||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -35,11 +35,12 @@ class InviteRepositoryTest extends TestCase
|
||||
return $d['email'] === '[email protected]'
|
||||
&& $d['token'] === 'tok123'
|
||||
&& $d['kind'] === Invite::KIND_PERSONAL
|
||||
&& $d['offering_id'] === null
|
||||
&& $d['status'] === Invite::STATUS_PENDING
|
||||
&& $d['invited_by'] === 2
|
||||
&& $d['expires_at'] === null;
|
||||
}),
|
||||
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
|
||||
['%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
$this->db->insert_id = 5;
|
||||
|
||||
|
||||
@@ -155,8 +155,42 @@ class InviteTest extends TestCase
|
||||
{
|
||||
$arr = (new Invite('[email protected]', 'tok', id: 1))->toArray();
|
||||
|
||||
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) {
|
||||
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at', 'offering_id'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testOfferingIdRoundTrips(): void
|
||||
{
|
||||
$invite = Invite::fromRow((object) [
|
||||
'id' => '5',
|
||||
'email' => '[email protected]',
|
||||
'token' => 'tok123',
|
||||
'role' => RoleManager::STUDENT,
|
||||
'status' => Invite::STATUS_PENDING,
|
||||
'invited_by' => '2',
|
||||
'accepted_user_id' => null,
|
||||
'accepted_at' => null,
|
||||
'offering_id' => '8',
|
||||
]);
|
||||
|
||||
self::assertSame(8, $invite->offeringId);
|
||||
self::assertSame(8, $invite->toArray()['offering_id']);
|
||||
}
|
||||
|
||||
public function testOfferingIdDefaultsToNullWhenColumnMissing(): void
|
||||
{
|
||||
$invite = Invite::fromRow((object) [
|
||||
'id' => '5',
|
||||
'email' => '[email protected]',
|
||||
'token' => 'tok123',
|
||||
'role' => RoleManager::STUDENT,
|
||||
'status' => Invite::STATUS_PENDING,
|
||||
'invited_by' => null,
|
||||
'accepted_user_id' => null,
|
||||
'accepted_at' => null,
|
||||
]);
|
||||
|
||||
self::assertNull($invite->offeringId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +81,42 @@ class RegistrationMailerTest extends TestCase
|
||||
{
|
||||
self::assertFalse((new RegistrationMailer())->sendRejected(''));
|
||||
}
|
||||
|
||||
public function testSendClassAccessGrantedEmailsTheStudent(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, 'Choir'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue((new RegistrationMailer())->sendClassAccessGranted($this->user('[email protected]'), 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendClassAccessGrantedReturnsFalseWithoutRecipient(): void
|
||||
{
|
||||
self::assertFalse((new RegistrationMailer())->sendClassAccessGranted($this->user(''), 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendClassInviteIncludesTheLink(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, 'http://join.test'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue((new RegistrationMailer())->sendClassInvite('[email protected]', 'http://join.test', 'Choir'));
|
||||
}
|
||||
|
||||
public function testSendClassInviteReturnsFalseWithoutRecipient(): void
|
||||
{
|
||||
self::assertFalse((new RegistrationMailer())->sendClassInvite('', 'http://join.test', 'Choir'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,17 @@ use Unsupervised\Schedular\Auth\Invite;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
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 RegistrationPageTest extends TestCase
|
||||
@@ -28,27 +33,45 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
||||
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
|
||||
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
|
||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
||||
|
||||
$invites = Mockery::mock(InviteRepository::class);
|
||||
$policies = Mockery::mock(PolicyRepository::class);
|
||||
$questions = Mockery::mock(QuestionRepository::class);
|
||||
$answers = Mockery::mock(AnswerRepository::class);
|
||||
$policies->shouldReceive('findForScope')->andReturn([])->byDefault();
|
||||
$questions->shouldReceive('findByScope')->andReturn([])->byDefault();
|
||||
$answers->shouldReceive('insert')->andReturn(1)->byDefault();
|
||||
|
||||
$access = Mockery::mock(GroupAccessRepository::class);
|
||||
$access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault();
|
||||
|
||||
$this->ctx = [
|
||||
'invites' => $invites,
|
||||
'policies' => $policies,
|
||||
'questions' => $questions,
|
||||
'answers' => $answers,
|
||||
'access' => $access,
|
||||
'mailer' => Mockery::mock(RegistrationMailer::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(
|
||||
$invites,
|
||||
$policies,
|
||||
Mockery::mock(PolicyVersionRepository::class),
|
||||
Mockery::mock(AcceptanceRepository::class),
|
||||
$this->ctx['versions'],
|
||||
$this->ctx['acceptances'],
|
||||
$this->ctx['settings'],
|
||||
$this->ctx['mailer'],
|
||||
$questions,
|
||||
$answers,
|
||||
$access,
|
||||
);
|
||||
|
||||
$_POST = [];
|
||||
@@ -96,6 +119,26 @@ class RegistrationPageTest extends TestCase
|
||||
self::assertSame('invite', $this->submit($invite, false));
|
||||
}
|
||||
|
||||
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
Functions\when('is_wp_error')->justReturn(false);
|
||||
|
||||
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||
Functions\when('wp_set_current_user')->justReturn(null);
|
||||
Functions\when('wp_set_auth_cookie')->justReturn(null);
|
||||
|
||||
// A personal invite tied to a class grant links the new account to it.
|
||||
$this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('[email protected]', 42)->andReturn(true);
|
||||
|
||||
$invite = new Invite(email: '[email protected]', token: 'hash', offeringId: 8);
|
||||
|
||||
self::assertSame('invite', $this->submit($invite, false));
|
||||
}
|
||||
|
||||
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
@@ -309,4 +352,152 @@ class RegistrationPageTest extends TestCase
|
||||
self::assertNotSame('confirm', $result);
|
||||
self::assertNotSame('', $result);
|
||||
}
|
||||
|
||||
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Functions\when('is_email')->justReturn(true);
|
||||
|
||||
$question = new Question(null, 'Emergency contact', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||
|
||||
// A missing required answer must be caught before any account is created.
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
$this->ctx['answers']->shouldReceive('insert')->never();
|
||||
|
||||
$result = $this->submit(null, true);
|
||||
|
||||
self::assertNotSame('confirm', $result);
|
||||
self::assertNotSame('', $result);
|
||||
}
|
||||
|
||||
public function testRecordsAccountAnswersOnSuccess(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'display_name' => 'Ada',
|
||||
'us_answers' => [ '5' => 'By a friend' ],
|
||||
];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
Functions\when('is_wp_error')->justReturn(false);
|
||||
Functions\expect('wp_set_current_user')->once();
|
||||
Functions\expect('wp_set_auth_cookie')->once();
|
||||
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||
|
||||
$question = new Question(null, 'How did you hear about us?', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||
|
||||
// The answer is written against the new user (account scope).
|
||||
$this->ctx['answers']->shouldReceive('insert')
|
||||
->once()
|
||||
->with(Mockery::on(static function (Answer $answer): bool {
|
||||
return $answer->registrationType === Answer::REG_ACCOUNT
|
||||
&& $answer->registrationId === 42
|
||||
&& $answer->studentId === 42
|
||||
&& $answer->questionId === 5
|
||||
&& $answer->answerValue === 'By a friend';
|
||||
}))
|
||||
->andReturn(1);
|
||||
|
||||
$invite = new Invite(email: '[email protected]', token: 'hash');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ class StudentActionsTest extends TestCase
|
||||
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
|
||||
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
|
||||
$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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Payment\Credit;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
@@ -27,6 +29,7 @@ class StudentHistoryTest extends TestCase
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private CreditRepository&Mockery\MockInterface $credits;
|
||||
private StudentHistory $history;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -39,6 +42,7 @@ class StudentHistoryTest extends TestCase
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->credits = Mockery::mock(CreditRepository::class);
|
||||
|
||||
$this->history = new StudentHistory(
|
||||
$this->acceptances,
|
||||
@@ -46,7 +50,8 @@ class StudentHistoryTest extends TestCase
|
||||
$this->policyVersions,
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->payments
|
||||
$this->payments,
|
||||
$this->credits
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +130,44 @@ class StudentHistoryTest extends TestCase
|
||||
self::assertSame('—', $rows[0]['answer']);
|
||||
}
|
||||
|
||||
public function testIntakeAnswersExcludeAccountScopeAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1),
|
||||
]);
|
||||
// Only the booking-scoped answer is resolved; the account answer is dropped.
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
->andReturn(new Question(1, 'Experience level', id: 4));
|
||||
|
||||
$rows = $this->history->intakeAnswers(5);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('Experience level', $rows[0]['question']);
|
||||
self::assertSame('Lesson #12', $rows[0]['context']);
|
||||
}
|
||||
|
||||
public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([
|
||||
new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4),
|
||||
new Question(null, 'Anything else?', isRequired: false, scope: Question::SCOPE_ACCOUNT, id: 7),
|
||||
]);
|
||||
|
||||
$rows = $this->history->registrationInfo(5);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
[ 'question' => 'Consent to email', 'answer' => 'Yes', 'required' => true ],
|
||||
[ 'question' => 'Anything else?', 'answer' => '—', 'required' => false ],
|
||||
],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
|
||||
public function testPaymentsBuildDisplayRows(): void
|
||||
{
|
||||
$this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
@@ -177,4 +220,34 @@ class StudentHistoryTest extends TestCase
|
||||
self::assertSame('Enrolment #3', $rows[0]['context']);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class UserNameTest extends TestCase
|
||||
{
|
||||
private function user(string $first, string $last, string $nickname): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->first_name = $first;
|
||||
$user->last_name = $last;
|
||||
$user->nickname = $nickname;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testPrefersFirstAndLastName(): void
|
||||
{
|
||||
self::assertSame('Ada Lovelace', UserName::format($this->user('Ada', 'Lovelace', 'ada_login')));
|
||||
}
|
||||
|
||||
public function testUsesFirstNameAloneWhenLastNameMissing(): void
|
||||
{
|
||||
self::assertSame('Ada', UserName::format($this->user('Ada', '', 'ada_login')));
|
||||
}
|
||||
|
||||
public function testFallsBackToNicknameWhenNoRealName(): void
|
||||
{
|
||||
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
|
||||
}
|
||||
|
||||
public function testFallsBackToIdWhenNothingSet(): void
|
||||
{
|
||||
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
|
||||
}
|
||||
|
||||
public function testReturnsFallbackIdWhenUserMissing(): void
|
||||
{
|
||||
self::assertSame('42', UserName::format(null, 42));
|
||||
self::assertSame('', UserName::format(null));
|
||||
}
|
||||
}
|
||||
@@ -343,4 +343,37 @@ class AvailabilityRepositoryTest extends TestCase
|
||||
self::assertCount(1, $slots);
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
|
||||
}
|
||||
|
||||
public function testFindOverlappingQueriesTheInstructorAndWindow(): void
|
||||
{
|
||||
$row = (object) [
|
||||
'id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'offering_id' => null,
|
||||
'start_dt' => '2026-09-08 16:00:00',
|
||||
'end_dt' => '2026-09-08 17:00:00',
|
||||
'duration_minutes' => '60',
|
||||
'is_booked' => '0',
|
||||
'recurrence_group' => null,
|
||||
];
|
||||
|
||||
// Half-open overlap: start_dt < window end AND end_dt > window start, with
|
||||
// the window bounds bound in that order.
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/start_dt < %s AND end_dt > %s/'),
|
||||
'wp_us_availability',
|
||||
3,
|
||||
'2026-09-08 17:00:00',
|
||||
'2026-09-08 16:00:00'
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
||||
|
||||
$slots = $this->repo->findOverlapping(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00');
|
||||
|
||||
self::assertCount(1, $slots);
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ class BlockPreviewTest extends TestCase
|
||||
self::assertStringContainsString('class="us-class"', $html);
|
||||
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
||||
self::assertStringContainsString('us-editor-note', $html);
|
||||
self::assertStringContainsString('A sample class shown so the page can be styled.', $html);
|
||||
}
|
||||
|
||||
public function testSingleClassGroupPreviewDropsTheDescriptionButKeepsScheduleAndEnrolment(): void
|
||||
{
|
||||
$html = BlockPreview::groupClasses(true);
|
||||
|
||||
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $html);
|
||||
self::assertStringContainsString('class="us-class-when"', $html);
|
||||
self::assertStringContainsString('class="us-enrol-deadline"', $html);
|
||||
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
||||
}
|
||||
|
||||
public function testLoginPreviewIncludesTheRealLoginTemplate(): void
|
||||
|
||||
@@ -126,7 +126,7 @@ class BlockRegistrarTest extends TestCase
|
||||
array_keys($registered['us-scheduler/student-login']['attributes'])
|
||||
);
|
||||
self::assertSame(
|
||||
['loginPageId'],
|
||||
['loginPageId', 'inviteOnlyMessage'],
|
||||
array_keys($registered['us-scheduler/student-register']['attributes'])
|
||||
);
|
||||
self::assertSame(
|
||||
@@ -179,6 +179,19 @@ class BlockRegistrarTest extends TestCase
|
||||
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
|
||||
}
|
||||
|
||||
public function testEditorPreviewOfAPinnedGroupClassOmitsTheDescription(): void
|
||||
{
|
||||
$this->registrar->preview = true;
|
||||
$this->groupClassPage->shouldNotReceive('render');
|
||||
|
||||
$all = $this->registrar->renderGroupClasses();
|
||||
$single = $this->registrar->renderGroupClasses(['offeringId' => 12]);
|
||||
|
||||
self::assertStringContainsString('A sample class shown so the page can be styled.', $all);
|
||||
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $single);
|
||||
self::assertStringContainsString('us-enrol-deadline', $single);
|
||||
}
|
||||
|
||||
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
|
||||
{
|
||||
// REST_REQUEST is undefined in the test process, so the real
|
||||
|
||||
@@ -9,11 +9,13 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -24,6 +26,7 @@ class BookingEndpointTest extends TestCase
|
||||
private OfferingRepository $offerings;
|
||||
private RegistrationGate $gate;
|
||||
private PaymentService $payments;
|
||||
private StudioSettings $settings;
|
||||
private BookingEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -34,12 +37,20 @@ class BookingEndpointTest extends TestCase
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
// Fixed "now" well before the fixture slot start (2026-07-01 10:00), so
|
||||
// the cancellation cutoff never trips unless a test moves it.
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->settings = Mockery::mock(StudioSettings::class);
|
||||
$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->availability,
|
||||
@@ -47,6 +58,7 @@ class BookingEndpointTest extends TestCase
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
new CancellationPolicy($this->settings),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,10 +386,153 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testScheduledBillingDefersPaymentAndConfirmsLesson(): void
|
||||
{
|
||||
// Weekly/monthly offerings are billed later by the daily scan, not at
|
||||
// booking: no payment is created now, and the reserved lesson is confirmed.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, billingMode: Offering::BILLING_WEEKLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]);
|
||||
$result = $this->endpoint->book($request);
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonInAlreadyBilledMonthChargesAtBooking(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson booked into June (its billing 1st
|
||||
// already reached) is an add-on and must be charged at booking, not deferred.
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(
|
||||
new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10)
|
||||
);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
|
||||
// Charged now, for a single lesson's fee, as a normal (non-scheduled) payment.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null)
|
||||
->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12));
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']);
|
||||
self::assertNotNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testMonthlyLessonBeforeBillingDateDefersPayment(): void
|
||||
{
|
||||
// "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st,
|
||||
// so it defers to the daily scan (no payment now, lesson confirmed).
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8)
|
||||
);
|
||||
$this->gate->shouldReceive('validate')->andReturn(null);
|
||||
$this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(77);
|
||||
$this->gate->shouldReceive('record')->once();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true);
|
||||
|
||||
$result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']);
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void
|
||||
{
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
$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();
|
||||
|
||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
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
|
||||
{
|
||||
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
|
||||
// exactly the studio cutoff — inside the window, so cancellation closes.
|
||||
$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(new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-06-02 09:59:00',
|
||||
endDt: '2026-06-02 10:59:00',
|
||||
id: 10,
|
||||
));
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
$this->availability->shouldNotReceive('release');
|
||||
$this->payments->shouldNotReceive('voidPending');
|
||||
|
||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('cancellation_closed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testCancelUsesOfferingCutoffOverrideWhenSet(): void
|
||||
{
|
||||
// Offering overrides the 24h studio default with 0 hours — cancel any time,
|
||||
// even for a lesson starting in a minute.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 4, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-06-01 10:01:00',
|
||||
endDt: '2026-06-01 11:01:00',
|
||||
id: 10,
|
||||
));
|
||||
$this->offerings->shouldReceive('findById')->with(4)->andReturn(new Offering(
|
||||
instructorId: 3,
|
||||
kind: Offering::KIND_PRIVATE_LESSON,
|
||||
title: 'Trial',
|
||||
cancellationCutoffHours: 0,
|
||||
));
|
||||
$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();
|
||||
@@ -484,4 +639,22 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
|
||||
self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']);
|
||||
}
|
||||
|
||||
public function testMyLessonsIncludesBookedOfferingName(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, status: Lesson::STATUS_PENDING, id: 77);
|
||||
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 8));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano Lesson', durationMinutes: 60, id: 8)
|
||||
);
|
||||
|
||||
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
|
||||
|
||||
$data = $result->get_data();
|
||||
self::assertSame('Piano Lesson', $data[0]['offering_title']);
|
||||
self::assertSame(60, $data[0]['duration_minutes']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,41 @@ class BookingRepositoryTest extends TestCase
|
||||
self::assertSame(15, $lessons[0]->id);
|
||||
}
|
||||
|
||||
public function testFindUnbilledScheduledLessonsJoinsOfferingAndFiltersUnbilled(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/l.status != %s.*l.payment_id IS NULL.*o.billing_mode IN \( %s, %s \)/s'),
|
||||
'wp_us_lessons',
|
||||
'wp_us_availability',
|
||||
'wp_us_offerings',
|
||||
Lesson::STATUS_CANCELLED,
|
||||
'weekly',
|
||||
'monthly'
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$row = (object) [
|
||||
'id' => '15',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'offering_id' => '9',
|
||||
'start_dt' => '2026-07-15 18:00:00',
|
||||
'billing_mode' => 'weekly',
|
||||
'title' => 'Piano',
|
||||
'price' => '35.00',
|
||||
'currency' => 'CAD',
|
||||
'etransfer_email' => null,
|
||||
];
|
||||
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
||||
|
||||
$rows = $this->repo->findUnbilledScheduledLessons();
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('15', $rows[0]->id);
|
||||
}
|
||||
|
||||
public function testCountUpcomingForStudent(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-06-08 12:00:00');
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class CancellationPolicyTest extends TestCase
|
||||
{
|
||||
private function policy(int $studioDefaultHours): CancellationPolicy
|
||||
{
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('cancellationCutoffHours')->andReturn($studioDefaultHours);
|
||||
|
||||
return new CancellationPolicy($settings);
|
||||
}
|
||||
|
||||
public function testCutoffHoursFallsBackToStudioDefaultWhenOverrideNull(): void
|
||||
{
|
||||
self::assertSame(24, $this->policy(24)->cutoffHours(null));
|
||||
}
|
||||
|
||||
public function testCutoffHoursUsesOverrideWhenSet(): void
|
||||
{
|
||||
// Even a zero override wins over the studio default — it is a deliberate
|
||||
// "cancel any time" choice, not an absent value.
|
||||
self::assertSame(0, $this->policy(24)->cutoffHours(0));
|
||||
self::assertSame(72, $this->policy(24)->cutoffHours(72));
|
||||
}
|
||||
|
||||
public function testCutoffHoursIgnoresNegativeOverride(): void
|
||||
{
|
||||
self::assertSame(24, $this->policy(24)->cutoffHours(-5));
|
||||
}
|
||||
|
||||
public function testStudentMayCancelWhenOutsideWindow(): void
|
||||
{
|
||||
// 48h before a 24h cutoff — comfortably outside.
|
||||
self::assertTrue(
|
||||
$this->policy(24)->studentMayCancel('2026-07-03 10:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testStudentMayNotCancelWhenInsideWindow(): void
|
||||
{
|
||||
// 12h before a 24h cutoff — inside the window.
|
||||
self::assertFalse(
|
||||
$this->policy(24)->studentMayCancel('2026-07-01 22:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testExactlyAtCutoffBoundaryIsAllowed(): void
|
||||
{
|
||||
// Exactly 24h ahead of a 24h cutoff is still cancellable.
|
||||
self::assertTrue(
|
||||
$this->policy(24)->studentMayCancel('2026-07-02 10:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testZeroCutoffAlwaysAllowsCancellation(): void
|
||||
{
|
||||
self::assertTrue(
|
||||
$this->policy(0)->studentMayCancel('2026-07-01 10:00:01', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testOfferingOverrideNarrowsWindow(): void
|
||||
{
|
||||
// Studio default is 0 (any time), but this offering demands 48h notice.
|
||||
self::assertFalse(
|
||||
$this->policy(0)->studentMayCancel('2026-07-02 10:00:00', 48, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testPastLessonCannotBeCancelled(): void
|
||||
{
|
||||
self::assertFalse(
|
||||
$this->policy(24)->studentMayCancel('2026-07-01 09:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testDescribeCutoffUsesDaysForWholeDays(): void
|
||||
{
|
||||
$policy = $this->policy(24);
|
||||
|
||||
self::assertSame('1 day', $policy->describeCutoff(24));
|
||||
self::assertSame('2 days', $policy->describeCutoff(48));
|
||||
}
|
||||
|
||||
public function testDescribeCutoffUsesHoursOtherwise(): void
|
||||
{
|
||||
$policy = $this->policy(24);
|
||||
|
||||
self::assertSame('12 hours', $policy->describeCutoff(12));
|
||||
self::assertSame('1 hour', $policy->describeCutoff(1));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -18,6 +20,8 @@ class LessonControllerTest extends TestCase
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('9:00 AM', $html);
|
||||
}
|
||||
|
||||
public function testListViewShowsBookedOfferingName(): void
|
||||
{
|
||||
$_GET['usc_view'] = 'list';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('lesson_id=1', $html);
|
||||
}
|
||||
|
||||
public function testLessonIdRoutesToDetailWithAnswersAndPolicies(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
$this->bookings->shouldNotReceive('findAllUpcoming');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Lesson details', $html);
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('Skill level', $html);
|
||||
self::assertStringContainsString('Beginner', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
}
|
||||
|
||||
public function testInstructorCannotOpenAnotherInstructorsLessonDetail(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('get_current_user_id')->justReturn(99);
|
||||
|
||||
// The lesson belongs to instructor 3, not the current user (99).
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->detail->shouldNotReceive('answers');
|
||||
$this->detail->shouldNotReceive('acceptances');
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('could not be found', $html);
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonDetailTest extends TestCase
|
||||
{
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private LessonDetail $detail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
|
||||
$this->detail = new LessonDetail(
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->acceptances,
|
||||
$this->policies,
|
||||
$this->versions
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
|
||||
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
|
||||
]);
|
||||
|
||||
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
|
||||
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => '#9', 'answer' => '—'],
|
||||
],
|
||||
$this->detail->answers(7)
|
||||
);
|
||||
}
|
||||
|
||||
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
|
||||
{
|
||||
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
|
||||
new PolicyAcceptance(
|
||||
policyVersionId: 4,
|
||||
studentId: 5,
|
||||
registrationType: PolicyAcceptance::REG_LESSON,
|
||||
registrationId: 7,
|
||||
ipAddress: '1.2.3.4',
|
||||
acceptedAt: '2026-07-01 10:00:00'
|
||||
),
|
||||
]);
|
||||
|
||||
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
[
|
||||
'policy' => 'Cancellation',
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
$this->detail->acceptances(7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -20,6 +22,7 @@ class EnrollmentEndpointTest extends TestCase
|
||||
private OfferingRepository $offerings;
|
||||
private RegistrationGate $gate;
|
||||
private PaymentService $payments;
|
||||
private GroupAccessRepository $access;
|
||||
private EnrollmentEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -30,17 +33,20 @@ class EnrollmentEndpointTest extends TestCase
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
Functions\when('current_time')->justReturn('2026-07-24');
|
||||
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->access = Mockery::mock(GroupAccessRepository::class);
|
||||
|
||||
$this->endpoint = new EnrollmentEndpoint(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
$this->access,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +55,11 @@ class EnrollmentEndpointTest extends TestCase
|
||||
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8);
|
||||
}
|
||||
|
||||
private function inviteOnlyOffering(): Offering
|
||||
{
|
||||
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
|
||||
}
|
||||
|
||||
private function expectSuccessfulEnrollment(): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
@@ -98,4 +109,147 @@ class EnrollmentEndpointTest extends TestCase
|
||||
$result->get_data()['payment']
|
||||
);
|
||||
}
|
||||
|
||||
public function testScheduledBillingEnrollmentDefersPayment(): void
|
||||
{
|
||||
// A monthly group class is billed later by the daily scan, so enrolment
|
||||
// succeeds with no payment created now.
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 120.0, billingMode: Offering::BILLING_MONTHLY, id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(201, $result->get_status());
|
||||
self::assertNull($result->get_data()['payment']);
|
||||
}
|
||||
|
||||
public function testRejectsEnrollmentAfterExplicitDeadline(): void
|
||||
{
|
||||
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('enrollment_closed', $result->get_error_code());
|
||||
self::assertSame(403, $result->error_data['enrollment_closed']['status']);
|
||||
}
|
||||
|
||||
public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void
|
||||
{
|
||||
// No explicit deadline, so it defaults to term_start (the first class day),
|
||||
// which is in the past relative to the stubbed 2026-07-24 "today".
|
||||
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->never();
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('enrollment_closed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->access->shouldReceive('hasGrant')->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('invite_required', $result->get_error_code());
|
||||
self::assertSame(403, $result->error_data['invite_required']['status']);
|
||||
}
|
||||
|
||||
public function testInviteOnlyClassAllowsGrantedStudentAndMarksEnrolled(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
||||
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(true);
|
||||
$this->expectSuccessfulEnrollment();
|
||||
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
|
||||
|
||||
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
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]);
|
||||
}
|
||||
|
||||
public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::pattern('/e.status = %s.*o.billing_mode IN \( %s, %s \)/s'),
|
||||
'wp_us_group_enrollments',
|
||||
'wp_us_offerings',
|
||||
Enrollment::STATUS_ACTIVE,
|
||||
'weekly',
|
||||
'monthly'
|
||||
)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_results')->andReturn([
|
||||
(object) [
|
||||
'id' => '12',
|
||||
'offering_id' => '7',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment_id' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$found = $this->repo->findActiveByBillingModes(['weekly', 'monthly']);
|
||||
|
||||
self::assertCount(1, $found);
|
||||
self::assertInstanceOf(Enrollment::class, $found[0]);
|
||||
}
|
||||
|
||||
public function testFindActiveByBillingModesReturnsEmptyForNoModes(): void
|
||||
{
|
||||
$this->db->shouldNotReceive('prepare');
|
||||
|
||||
self::assertSame([], $this->repo->findActiveByBillingModes([]));
|
||||
}
|
||||
|
||||
public function testUpdateStatusRejectsInvalid(): void
|
||||
{
|
||||
self::assertFalse($this->repo->updateStatus(1, 'bogus'));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user