Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58585be0d
|
||
|
|
ae1a62883e
|
||
|
|
1847159e31
|
||
|
|
74df3f5ba8
|
||
|
|
f8f211929c | ||
|
|
61a40f0e8d | ||
|
|
88a8d0ae5c
|
||
|
|
e2da45a1b0
|
||
|
|
dfa29745df | ||
|
|
54a8b906f7
|
||
|
|
b3ed3a67d5 | ||
|
|
908b7fcd1f
|
||
|
|
b17adf02ff
|
||
|
|
a23490ec80 | ||
|
|
1552bf4b5f
|
||
|
|
f8762e1095
|
||
|
|
c9d18fec74
|
||
|
|
ab609898d6
|
||
|
|
4bc8e80837 | ||
|
|
28f586d207
|
||
|
|
e097b7a0bf | ||
|
|
572aaf5b49
|
||
|
|
116394f2ff | ||
|
|
dda8386c1f | ||
|
|
7278309bf5 | ||
|
|
5ce42f0003
|
||
|
|
76530878b5 | ||
|
|
6031a75012 | ||
|
|
41843e5253 | ||
|
|
8c21a3fa9d
|
||
|
|
8a34ec41e9 | ||
|
|
37c8d2b39e
|
||
|
|
a90e06ae70
|
||
|
|
d5f6ebf0b5
|
||
|
|
4b4b2453ae
|
||
|
|
7f10769330 | ||
|
|
f6481d4a3f
|
||
|
|
43b903c1c8
|
||
|
|
d5eb2764a3
|
||
|
|
dd31afcd06
|
||
|
|
85c7a01939
|
||
|
|
bc046ec2a1
|
||
|
|
b814ae34b4 | ||
|
|
9071a3f70f
|
||
|
|
170d7e6c21 | ||
|
|
aa3dd13775 | ||
|
|
164c8ebf97 | ||
|
|
1291af0b72
|
||
|
|
78083fc96c | ||
|
|
c077a653fb
|
||
|
|
f9e222be29 | ||
|
|
b950e35e5a | ||
|
|
b220de48c5 | ||
|
|
8017dbb9ff
|
||
|
|
c73b10d779 | ||
|
|
7ea8d653ee | ||
|
|
1e4e21e8d3 | ||
|
|
df3462a8b3
|
||
|
|
748478f2f1 | ||
|
|
f97b8a4576
|
||
|
|
325a86f247 | ||
|
|
434fe801ba
|
||
|
|
84378e856b | ||
|
|
a2cece750b | ||
|
|
5d98aedfa5 | ||
|
|
8fd7bf983d
|
||
|
|
122f7a0f53
|
||
|
|
c9a1205fc0
|
||
|
|
cb347ffca0
|
||
|
|
258468093b | ||
|
|
3a4b25a711 | ||
|
|
969d864106 | ||
|
|
69179b75c9
|
||
|
|
4e5382e259
|
||
|
|
28046e0fd1 | ||
|
|
699e479805
|
||
|
|
1b42d20541 | ||
|
|
ab5212282d
|
||
|
|
6e3affb1cb
|
||
|
|
7875cb1bf7 | ||
|
|
04cba9702c
|
||
|
|
d554e35d80 | ||
|
|
b5b9a7ac54
|
||
|
|
f0149042cc | ||
|
|
1d2f95d388
|
||
|
|
2878beb221 | ||
|
|
7e2bba79fe
|
||
|
|
3a83decc82 | ||
|
|
76caf178f0
|
||
|
|
8013d05d68 | ||
|
|
6b29c0e78e
|
||
|
|
7ea6616ba0 | ||
|
|
7fdf97b073
|
||
|
|
d3843186c0 | ||
|
|
e44972abe9 | ||
|
|
3c41d1119d | ||
|
|
8122c158cf
|
||
|
|
b772e1811e
|
||
|
|
c25260a367 | ||
|
|
96aaeff79c | ||
|
|
bbc85d88f1 | ||
|
|
171b655bb8
|
||
|
|
d9dd576630 | ||
|
|
61b00c2ed3
|
||
|
|
7eb2afc6a3
|
||
|
|
8a985f04d6
|
||
|
|
da985c7f71 | ||
|
|
7c91e1eef7
|
||
|
|
f3917d0784 | ||
|
|
b508ab92f8
|
||
|
|
2a661a10ff | ||
|
|
95df78d384 | ||
|
|
fabbd35fa7 | ||
|
|
3a954bac57
|
||
|
|
907f665876 | ||
|
|
bfdc3b3380
|
||
|
|
a276d53c1b
|
||
|
|
9344ab7193
|
||
|
|
d6a515cc93 | ||
|
|
ae07930d6d | ||
|
|
3a25c397c5 | ||
|
|
264d9cba01
|
||
|
|
689ec833f3 | ||
|
|
0f30f28e92
|
||
|
|
9d11cc3b01
|
||
|
|
add6605141 | ||
|
|
edcacae816
|
||
|
|
17487cde46 | ||
|
|
13d6b3e14e
|
||
|
|
d866aa7295 | ||
|
|
c4acdb7ca4
|
||
|
|
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 |
@@ -5,7 +5,8 @@
|
||||
"Bash(composer lint *)",
|
||||
"Bash(tea actions:*)",
|
||||
"Bash(tea issue *)",
|
||||
"Bash(tea label *)"
|
||||
"Bash(tea label *)",
|
||||
"Bash(composer cs *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+37
-40
@@ -7,24 +7,31 @@ on:
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
# Jobs that need PHP run inside the shared CI images maintained in the
|
||||
# Unsupervised/ci-php repository. PHP, Composer, the intl and zip extensions
|
||||
# and the GNU CLI tools are already in the image, so there is no toolchain
|
||||
# setup step in any job here.
|
||||
#
|
||||
# The registry path is written out at each use because
|
||||
# jobs.<id>.container.image cannot read the `env` context.
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
phpcs:
|
||||
name: Coding Standards
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
|
||||
# COMPOSER_HOME is /composer in the image, so that is where the
|
||||
# download cache lives. composer.lock is what fingerprints the
|
||||
# dependency set.
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ hashFiles('composer.json') }}
|
||||
path: /composer/cache
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --no-interaction
|
||||
@@ -32,24 +39,19 @@ jobs:
|
||||
- name: Run PHPCS
|
||||
run: composer cs
|
||||
|
||||
|
||||
static-analysis:
|
||||
name: PHPStan
|
||||
phpstan:
|
||||
name: Static Analysis
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ hashFiles('composer.json') }}
|
||||
path: /composer/cache
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --no-interaction
|
||||
@@ -60,29 +62,26 @@ jobs:
|
||||
test:
|
||||
name: Tests (PHP ${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.unsupervised.ca/unsupervised/ci-php:${{ matrix.php }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# A version can only be added here once ci-php publishes the matching
|
||||
# tag.
|
||||
php:
|
||||
- '8.1'
|
||||
- '8.2'
|
||||
- '8.3'
|
||||
- '8.5'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mbstring, intl
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
|
||||
path: /composer/cache
|
||||
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --no-interaction
|
||||
@@ -90,6 +89,8 @@ jobs:
|
||||
- name: Run PHPUnit
|
||||
run: composer test
|
||||
|
||||
# Runs on the runner image rather than a container: it needs no PHP, and it
|
||||
# uses GNU grep's --include.
|
||||
no-debug:
|
||||
name: No Debug Code
|
||||
runs-on: ubuntu-latest
|
||||
@@ -106,19 +107,15 @@ jobs:
|
||||
build:
|
||||
name: Build Plugin Zip
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||
# Only build a shippable artifact once changes land on main, and only
|
||||
# after the quality gates pass.
|
||||
needs: [lint, static-analysis, test, no-debug]
|
||||
needs: [phpcs, phpstan, test, no-debug]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
|
||||
- name: Build plugin zip
|
||||
run: composer build
|
||||
|
||||
|
||||
@@ -15,15 +15,13 @@ jobs:
|
||||
release:
|
||||
name: Build and Publish Release
|
||||
runs-on: ubuntu-latest
|
||||
# The shared CI image carries composer, curl, jq and the GNU coreutils
|
||||
# the steps below shell out to. See docs/ci.md.
|
||||
container:
|
||||
image: git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
|
||||
# A tag that disagrees with the plugin header would make sites see a
|
||||
# phantom update forever (or never see a real one), so fail fast.
|
||||
- name: Verify tag matches plugin version
|
||||
@@ -37,6 +35,14 @@ jobs:
|
||||
fi
|
||||
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# COMPOSER_HOME is /composer in the image, so that is where the
|
||||
# download cache lives.
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /composer/cache
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress --no-interaction
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
coverage/
|
||||
.phpunit.result.cache
|
||||
*.log
|
||||
|
||||
+160
@@ -11,6 +11,166 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.5.6]
|
||||
|
||||
### Security
|
||||
- **Signing in on the front end no longer hands out a session cookie that can travel over plain HTTP.** The studio's own login form told WordPress not to work out for itself whether the site was secure, and WordPress took that as "it is not" — so on an HTTPS site every student's session cookie was issued without the flag that keeps a browser from ever sending it unencrypted. Anyone able to watch the network and provoke a single `http://` request to the site could have lifted a signed-in session with it. The form now leaves that judgement to WordPress, which is what the standard login screen has always done. Nothing changes for you; existing sessions are unaffected.
|
||||
- **Plugin updates are now only accepted from the release server itself.** The update check asks the repository where to download the new version and used to take whatever answer came back. An answer that was not really the release server's — a hijacked hostname, a tampered response — could have pointed the site at any file on the internet, which WordPress would then have unpacked over the plugin. The download address must now be `https` on `git.unsupervised.ca` exactly; a lookalike, a subdomain, or an unencrypted address is refused and no update is offered. Ordinary updates are unaffected.
|
||||
- **A student can no longer tell a booking that is not theirs from one that does not exist.** Cancelling someone else's lesson was already refused, but the refusal was worded differently from "no such booking" — enough for a signed-in student to work through the numbers and learn how many lessons the studio holds. Both now answer identically. Withdrawing from a group class was the same and has had the same treatment.
|
||||
- **Students created by anything other than the studio's own signup form now wait for approval.** Turning on open registration switches on WordPress's site-wide "anyone can register" setting and makes Student the default role for new accounts — which is what the studio's registration page needs, but it also arms any *other* signup form the site happens to have. An account created that way arrived able to book and be billed immediately, with no email confirmed, no approval and no policies agreed to. Any student account that appears without going through the studio's own form is now held and listed under **Students → Pending Students**, exactly like a self-signup; it can sign in, but cannot book until you approve it. Students you add yourself from wp-admin, invited students, and the children a parent adds are all unaffected.
|
||||
|
||||
### Added
|
||||
- **You can now decide what deleting the plugin takes with it, on Access → Plugin removal.** WordPress gives an uninstall nothing to ask you with, so the answer is given ahead of time. By default your records stay: delete the plugin and your lessons, enrolments, payments, credits, intake answers, policy agreements, invites and family links are still there when you reinstall, so a delete during a migration or a bit of troubleshooting costs you nothing. Ticking **Erase everything when the plugin is deleted** — which also asks you to type DELETE, because there is no undo — drops every table, setting and role the plugin made. Either way your Stripe secret key and webhook signing secret are now forgotten on deletion, where before they stayed in the database indefinitely: they take a minute to paste back in, and live keys on a site that no longer has the code to use them are worth nothing but risk. The two WordPress settings open registration borrows are put back as they were, too, so deleting the plugin can never leave the site quietly accepting signups into a role that no longer exists.
|
||||
|
||||
## [1.5.5]
|
||||
|
||||
### Changed
|
||||
- **Payments moved up to version 21 of Stripe's PHP library**, from version 17. Being four major versions behind also meant asking Stripe to behave like an older version of its API; the plugin now uses API version `2026-07-29.dahlia`. Taking a card payment and handling a webhook are unchanged — the same charge is raised, the same events are honoured, and a forged webhook is still rejected. Nothing to do on your side.
|
||||
|
||||
## [1.5.4]
|
||||
|
||||
### Fixed
|
||||
- **Adding students to a group class now checks that each one is actually a student.** **Add students directly** and **Make available** acted on whatever ids the form posted without confirming they named students at all, so a stale page — or a tampered submission — could put an instructor, an administrator, or an account that had since been deleted onto a class roster, raising a real payment against them. Both controls now skip anything that is not a student, and the "%d student(s) added" count tells you how many actually went through. Children and students awaiting approval are unaffected: they are students, and adding them is what these controls are for.
|
||||
- **A refused booking no longer empties the Book a lesson for a student form.** Whatever the reason it came back — the time taken while you were typing, a weekly reservation asked for on a time that does not repeat — the panel reopens with the student, time, lesson type, both ticks and your note exactly as you left them, so a correction is one field, not five. A booking that goes through still leaves an empty form behind for the next one.
|
||||
- **Booking a lesson for a child, or for a student you have not approved yet, no longer fails with "Choose a student to book for."** The **Book a lesson for a student** panel offered every student it could see, but then refused a good half of them: a parent's child and a self-signup still awaiting approval both appeared in the list, and both were rejected on submit — with an error that read as though no student had been chosen, and which cleared the form. Neither of those accounts is allowed to book *in their own name* (a child's is never signed in to at all, and an unapproved signup waits for you), and the panel was mistakenly applying that same restriction to the studio booking on their behalf, which is precisely the case it was built for. Anyone the panel offers can now be booked for.
|
||||
|
||||
## [1.5.3]
|
||||
|
||||
### Added
|
||||
- **Intake answers and policy agreements can now be recorded after the fact for a lesson the studio booked, or a student it added straight into a group class.** A lesson booked from wp-admin has no answers and no signed policies — nobody was at a keyboard to give them — and until now there was nowhere to put them once the studio did collect them at the first lesson or over the phone. The lesson's detail page now carries **Record intake collected elsewhere**, offering whatever is still outstanding: the unanswered questions and the policy versions with no acceptance on file. Fill in what you have, leave the rest, come back later — nothing already recorded can be overwritten, and a form posted twice cannot duplicate anything. Every recording must say **how** it was collected — a signed paper form, in person, over the phone, by email, or some other way you describe — and that answer is stamped on each entry along with your name. Both audit tables gained a **How it was given** column, so a policy accepted online and one transcribed from paper can never again look like the same thing. Only registrations the studio made have the panel: one a student made already holds their own answers, and those stay theirs alone. Group classes work the same way, reached from the new **Intake → View** link on each roster row of a class's detail page — a student added with **Add students directly** was never shown the enrolment form, and this is where what you collect instead now goes.
|
||||
- **You can now book a lesson for a student yourself, from Scheduler or My Lessons.** Group classes have always had **Add students directly**, but a private lesson could only be booked by the student — or by their parent, for a child — so a booking taken over the phone, or a make-up lesson an instructor wanted to slot in, had no way in short of asking the family to go and do it themselves. **Book a lesson for a student**, a panel at the top of both lesson pages, takes the student, an open time and the lesson type and books it there and then. Tick **Reserve this time weekly** to hold the same time for the rest of the term, or **No charge** for a make-up or goodwill lesson — that one skips payment entirely and confirms the lesson immediately, where an ordinary booking raises a pending payment at the lesson type's price and confirms when it settles, exactly as a student's own booking does. The **Scheduler** reaches every instructor's open times; **My Lessons** shows an instructor only their own. Booking this way does not ask the intake questions or record the policy agreements the student would give themselves — those stay theirs to answer, so a lesson booked for someone simply shows none on its detail page.
|
||||
|
||||
## [1.5.2]
|
||||
|
||||
### Added
|
||||
- **You can now choose which payment method the studio bills by, instead of it following your Stripe keys.** Saving Stripe keys used to move every student onto credit-card billing the moment they were entered — there was no way to have Stripe live and still bill by e-transfer while you satisfied yourself that card payments worked. **Studio Settings → Billing → Default payment method** now makes that an explicit choice between **Credit card** and **E-transfer**. Leaving it on E-transfer with Stripe configured lets you switch one student at a time to Credit card on their student detail page and watch their bookings charge for real; when you are satisfied, changing this one setting moves everyone over. Credit card remains the default, so a studio that adds keys and changes nothing else behaves exactly as before, and it still falls back to e-transfer until keys are saved — a card cannot be charged without them.
|
||||
- **Stripe can now be disconnected from Studio Settings.** Keys could be replaced but never removed, so a studio that set Stripe up to try it had no way back to e-transfer short of editing the database. **Clear Stripe configuration**, at the foot of the settings page whenever any Stripe value is stored, forgets the publishable key, the secret key and the webhook signing secret, and returns the mode to Test — billing falls back to e-transfer until keys are entered again. Payments already recorded are untouched, as are your currency, HST, e-transfer and registration settings. If you are disconnecting for good, delete the webhook endpoint in the Stripe Dashboard too, or it will keep sending events this site can no longer verify.
|
||||
|
||||
## [1.5.1]
|
||||
|
||||
### Fixed
|
||||
- **A parent can now enrol more than one child in the same group class.** Enrolling the first student worked, and then the class card switched to "You are enrolled in this class." with a **Withdraw** button — for the whole account. There was no way to sign up a second child short of withdrawing the first, even though nothing was ever actually full or forbidden: the class page was matching enrolments to the account rather than to the student, so one child's seat spoke for everybody. Each enrolled student now gets their own line on the card, named — "Ada is enrolled in this class." — with their own Withdraw button, and the Enrol button stays put, reading **Enrol another student**, until everyone on the account is in. The form's "Who is this for?" list offers only the students not yet enrolled, so the class cannot be double-booked for the same child by accident. Enrolments already recorded are unaffected; the seats were always separate on the studio's side, and this is the page catching up with that.
|
||||
|
||||
## [1.5.0]
|
||||
|
||||
### Added
|
||||
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
|
||||
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
|
||||
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
|
||||
|
||||
### Fixed
|
||||
- **A recurring lesson now shows the policies the student accepted on every week of it, not just the first.** Booking a weekly lesson reserves a series of them, and the student answers the intake questions and agrees to the studio's policies once, for the whole reservation. Opening any week after the first showed no answers and no policies accepted — as though nothing had been agreed to. Nothing was ever missing: the agreement was recorded against the first lesson of the series and every other week was looking for one of its own. Each week of a series now shows the intake answers and the full acceptance record — policy, version, when it was accepted, and from where — captured when the reservation was booked. Existing bookings read correctly straight away; there is nothing to re-collect from anyone.
|
||||
|
||||
## [1.4.1]
|
||||
|
||||
### Added
|
||||
- **Group classes now appear in "Your upcoming lessons".** A class is stored as a term rather than as bookable slots, so nothing that listed lessons could ever show one — a student whose whole term was a group class saw an empty schedule, and an instructor teaching one saw nothing on their My Lessons page. Each remaining session of a class you are enrolled in now sorts in among your lessons by date, labelled **group class**; instructors see every session of the classes they teach, one row per session however many students are in it. A session has no Cancel button, because there is no such thing as cancelling one date of a term — withdrawing from the class is still done from the class page.
|
||||
- A class you are enrolled in shows up **whether or not its schedule is pinned to a clock**. Class time and duration are both optional on the offering form, and the schedule note is there so a studio can simply write "Tuesdays 4:00pm" — so a class with a time but no duration lists its dates and says when each session starts rather than guessing when it ends, and a class with no time at all gets a single row carrying its schedule note (or its term dates) where the time would go. Only a class whose last day has passed drops off the list.
|
||||
- A student's admin detail page now shows **Booked by** in the Account section — the name of the parent or guardian who books and pays for them, linked to their own page. It was only stated further down under Profile, and only when there was one; the row is now always there, saying in words when a student books for themselves.
|
||||
- The same group-class sessions now appear in **Upcoming lessons** on a student's admin detail page, so one table answers "what are they booked into next week?". Only upcoming ones — the **Group-class enrolments** table below already holds the history.
|
||||
- **A policy can be renamed.** The title was fixed at creation, so a typo or a change of wording meant creating a second policy and re-collecting everyone's acceptance. Renaming changes only what students read above the policy text: the slug stays put, so every version already accepted stays attached.
|
||||
|
||||
### Fixed
|
||||
- **People are named by their name again, not their email address.** Anywhere the plugin named a person it could show their email instead — "Managed by grace@example.com" in the students table, the same under **Booked by**, and instructor names on the class pages. WordPress starts a new account's nickname off as its username, and signup uses the email address as the username, so the address became the nickname of every self-registered account; the name they had typed was sitting in the account's display name the whole time. Names are now read from there when the nickname turns out to be an address, so existing accounts read correctly with nothing to fix by hand, and new signups store the name properly in the first place. Students added by a parent were never affected.
|
||||
- **Deleting a parent now removes the students they booked for.** A managed student account has no login of its own and exists only so its parent has somebody to book for — with the parent gone nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding lesson times. Deleting a parent now releases each of their students' upcoming lessons and enrolments on the same terms as their own, and deletes the accounts. Removing a student from the family screen is unchanged and still refuses one with lessons on record.
|
||||
- **The upcoming-lessons panel no longer collapses onto itself in some themes.** Rows could render on top of one another and the status badge's colour could stop short of the text inside it. Both came from the same thing: the panel never stated its own line spacing, so a theme setting a line height of zero anywhere above it — a common icon-font reset — was inherited straight through, leaving each line of text taller than the space allotted to it. The panel now sets its own.
|
||||
- **Deleting a student now gives back what they had booked.** WordPress deletes a user without knowing anything about lessons, so their bookings were left behind: the times stayed marked as booked and nobody else could take them, the lessons stayed on the instructor's schedule under a name that no longer resolved, and a group class kept a seat filled by nobody. Deleting an account now cancels each of its upcoming lessons, frees the time for rebooking, cancels its active class enrolments, and voids any payment still pending on them. Past lessons are left exactly as they are — they happened, and the payment report has to keep adding up. A paid lesson is not credited back: a credit could only be spent on the account being deleted, so a refund owed to someone who has left stays the studio's decision to make.
|
||||
- **A weak password is now caught before the form is submitted, not after.** The strength meter scores the password as you type, but zxcvbn's dictionary arrives a moment after the page loads — so a password typed straight away was never scored at all, and the first you heard of it was the server rejecting the whole form. The password is now re-scored on submit, so the verdict is always the one your password actually earns.
|
||||
|
||||
### Changed
|
||||
- **Signup is one page again.** The studio's registration questions used to be a second step behind a **Next** button; they are now asked on the main form, in an **About you** panel above the students you are adding. What the studio needs to know about you is part of registering, not a sequel to it — and there is now one submit rather than three.
|
||||
- **Signup asks an adult student for their birth year**, the same four-digit year already asked of every student being registered on someone else's behalf. It is asked only when you are a student yourself — choosing **on behalf of one or more students** leaves the whole **About you** panel out, since those questions describe a student and in that case you are not one.
|
||||
|
||||
## [1.4.0]
|
||||
|
||||
### Added
|
||||
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
|
||||
|
||||
### Security
|
||||
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
|
||||
|
||||
### Changed
|
||||
- Signup now asks **"Who are you registering?"** as a three-way choice — **just myself**, **on behalf of one or more students**, or **both** — in place of the single parent/guardian tick. The tick could only ever say "I have children to add"; it could not say whether the account holder was a student themselves, so every account was offered its own name in the **Who is this for?** picker whether or not anyone meant to book them a lesson. Choosing *on behalf of* now leaves the account holder out of that picker. Existing accounts are unaffected and stay bookable, since the flag records only the new "not a student" case.
|
||||
- The studio's **account-signup questions are now asked of anyone registering as a student**, including someone registering themselves alongside their children. Choosing **both** previously collected the questions per child only, so the account holder's own instrument, level and the rest were never asked for or stored, even though they could book lessons. Their answers are recorded against their own account, and a blank required answer now names them rather than blaming "each student".
|
||||
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
|
||||
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
|
||||
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
|
||||
|
||||
### Fixed
|
||||
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
|
||||
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
### Added
|
||||
- **Parent and guardian accounts.** A parent registers once and manages lessons for one or more children, who need no login of their own. The signup form gains an **"I'm registering as a parent or guardian"** tick that reveals a block per child — name, date of birth, and the studio's account-signup questions asked **per child**, since those describe the student rather than the account holder. Signup policies are recorded once per child with the guardian named as the person who agreed, which is the record that actually means something: "this guardian accepted version N on behalf of this child, at this time, from this address." A guardian can also be a student themselves and book their own lessons from the same account.
|
||||
- A **"Who is this for?"** picker on the booking and group-class forms, listing **children first** and the account holder last — so the default selection is never the parent, and a lesson meant for a child is not quietly booked and billed in the parent's name. An account with only itself on the list sees no picker and behaves exactly as before. A guardian's upcoming-lessons panel covers the whole household, each row naming whose lesson it is, and they can cancel or withdraw for any of their children.
|
||||
- A **Family** page for guardians (`[us_family]`, or the **Family** block) to add, edit and remove children after signup. Removing a child is refused once they have lessons or enrolments on record — that history belongs to them, and the studio unpicks it by hand rather than the page orphaning it.
|
||||
- **One family, one bill.** Payments record the child the lesson was for *and* the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step all go to the parent. Account credit is held by the payer, so a credit from one child's cancelled lesson can settle a sibling's next charge, and the billing-method override (comp / card / e-transfer) is one setting on the guardian rather than one per child. The daily billing scan sends a guardian **one** notice covering every child, with each line naming whose lesson it is.
|
||||
- **Students** in wp-admin gains a **Family** column linking a child to their guardian and a guardian to their children, and the student screen gains a **Family** panel. A child's row shows the guardian's email — a child's own address is a placeholder that can never receive mail — and their credit balance is labelled with whose account actually holds it.
|
||||
|
||||
### Changed
|
||||
- Child accounts **cannot be signed in to**. They hold the student role so every existing lookup keeps working, but authentication is refused outright and the booking capability is withheld, so the only route to a lesson in a child's name is their guardian's authorised booking.
|
||||
|
||||
## [1.2.4]
|
||||
|
||||
### Fixed
|
||||
- **Adding availability no longer fails in silence.** Entering a window shorter than the chosen lesson length — 5:30–6:00 PM with the lesson length left on its default of 60 minutes, say — saved nothing and said nothing: the page just reloaded, whether the window was one-off or set to repeat for 41 weeks. The **Lesson length** menu now offers only the lengths that actually fit the window you have entered, and the form refuses to submit when none of them do. Every other way the form could quietly do nothing now explains itself too — an unreadable date, an end time before the start, a window running past midnight into the next day — and a successful save says how many bookable slots it created. Deleting says whether the slot went, and tells you when one is refused because it is already booked. Availability added through the API is checked against exactly the same rules, which it previously enforced slightly differently.
|
||||
- A student's **upcoming lessons no longer pile on top of each other**. On the booking page, the lesson name, its date and time, the status badge and the **Cancel** button could render over one another instead of sitting in a tidy row — worst with a long lesson-type name, and on narrow screens, where the row had no phone layout at all. The panel now keeps its shape whatever the theme around it does, long names wrap instead of shoving the Cancel button out of the row, and on a phone the lesson details stack above the buttons.
|
||||
- The registration page **no longer dead-ends a visitor who is already signed in**. It used to greet them with "You already have an account and are logged in." and nothing else, leaving them to find their own way to the studio. They now get a link onward to the page chosen under the block's **After registration** panel, and the link names it — "Continue to Book a Lesson" rather than the vaguer wording an invited student used to see. With no page chosen, the message appears on its own as before, because sending someone who is already signed in to the sign-in screen helps nobody.
|
||||
|
||||
## [1.2.3]
|
||||
|
||||
### Changed
|
||||
- A **monthly group class is now billed its price once per month**, however many times the class meets in that month. Previously the monthly charge multiplied the price by the number of sessions in the month — a class priced at `40.00 CAD` meeting weekly was billed `160.00 CAD` on the 1st — which no studio could quote honestly on a class card. A monthly **private lesson** is unchanged: its price is a per-lesson fee and the month is still billed one fee per lesson, which is why it is quoted per lesson. Studios running a monthly group class should check the class price now reads as the monthly fee they intend to charge.
|
||||
|
||||
### Added
|
||||
- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**.
|
||||
- The **Policies** admin page can now **show you what is actually in a version**. Every row in the versions table has a **View** button that opens that version's text below the table, rendered exactly as students see it at booking and signup, whether the version is the published one, an old archived one, or a draft nobody has seen yet. The text is editable straight from the viewer, and what happens when you save depends on the version: a draft is simply updated in place, while editing a **published or archived version saves your text as a new draft version** and leaves the original exactly as students accepted it. The new draft then opens in the viewer ready to publish. Nothing a student has agreed to is ever rewritten.
|
||||
- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block.
|
||||
|
||||
### Fixed
|
||||
- Policies are **readable where students have to accept them**. A policy typed as plain paragraphs — the normal way to write one, with no HTML — was being dropped into the booking, enrolment, and signup forms unformatted, collapsing the whole document into a single squashed line with a horizontal scrollbar and words piling on top of each other. Policy text is now formatted the same way WordPress formats post content, so blank lines become real paragraphs, and the acceptance box is styled as a proper bounded reading panel: long policies scroll vertically instead of running off the side of the page, long pasted links wrap rather than forcing the page sideways, and the "I have read and agree" tick stays in view. Policies written with HTML are unaffected. The studio registration page was also missing the plugin's stylesheet entirely, which is why the problem was at its worst there.
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
### Added
|
||||
- The **Lesson Booking** block gained three embedding options in its sidebar. **Lesson type** pins the block to a single private-lesson type — only the times bookable as that type are listed and it is the only thing bookable there, auto-selected on the registration form — so a page about one lesson type can carry its own calendar. **Show the lesson-type filter** turns the **Show Only** control on or off. **Sections** embeds just one half of the page: booking calendar only, or the student's upcoming lessons only, so the two can live on different pages. All three are available to the shortcode as `[us_booking lesson_type="…" show_filter="no" show="booking|upcoming"]`, and the block's editor preview follows the chosen sections.
|
||||
- The booking calendar now has a **Show Only** button beside the List/Week toggle that opens a lesson-type filter, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. The type list starts collapsed and can be tucked away again without losing the filter; the button shows how many types are ticked. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type.
|
||||
- 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.
|
||||
- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -4,100 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
composer install # Install all dependencies
|
||||
|
||||
composer test # Run the full test suite (required after every change)
|
||||
composer lint # PHPStan static analysis
|
||||
composer cs # PHPCS coding standards check
|
||||
composer cs:fix # Auto-fix coding standards
|
||||
|
||||
# Run a single test file
|
||||
./vendor/bin/phpunit tests/Unit/Availability/AvailabilityRepositoryTest.php
|
||||
|
||||
# Run a single test by name
|
||||
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
|
||||
```
|
||||
|
||||
**Run `composer test` after every code change before considering a task complete.**
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Bootstrap
|
||||
`unsupervised-schedular.php` defines constants (`USC_VERSION`, `USC_PLUGIN_DIR`, `USC_PLUGIN_URL`), registers activation/deactivation hooks, then calls `Plugin::boot()` on `plugins_loaded`. No logic lives in the root file.
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
|
||||
Availability/ — Availability slots: value object, repository, controller, REST endpoint
|
||||
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
|
||||
Auth/ — Roles, capabilities, login page
|
||||
Plugin.php — Wires all components together on plugins_loaded
|
||||
Installer.php — Creates DB tables and roles on activation
|
||||
Schema.php — CREATE TABLE SQL for dbDelta
|
||||
AdminMenu.php — Registers wp-admin menu pages
|
||||
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
|
||||
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
|
||||
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
|
||||
BlockPreview.php — Static editor-preview markup for the blocks
|
||||
templates/ — PHP view files included by controllers/shortcodes
|
||||
assets/ — CSS and JS (vanilla JS, no build step)
|
||||
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
|
||||
Availability/ — Tests for src/Availability/
|
||||
Booking/ — Tests for src/Booking/
|
||||
Auth/ — Tests for src/Auth/
|
||||
docs/features/ — One markdown file per feature describing data model, API, and test locations
|
||||
```
|
||||
|
||||
**Code is organised package-by-domain** (Availability, Booking, Auth). Each domain package contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
|
||||
### Code organisation
|
||||
**Code is organised package-by-domain.** Each domain package under `src/<Domain>/` contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
|
||||
|
||||
### Data Storage
|
||||
Two custom database tables (created via `dbDelta` on activation):
|
||||
- `{prefix}us_availability` — instructor availability windows
|
||||
- `{prefix}us_lessons` — booked lessons
|
||||
Custom database tables are created via `dbDelta` on activation; `Schema.php` holds the SQL.
|
||||
|
||||
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `Plugin` | Wires all components together on `plugins_loaded` |
|
||||
| `Installer` | Creates DB tables and roles on activation |
|
||||
| `Schema` | CREATE TABLE SQL strings for dbDelta |
|
||||
| `AdminMenu` | Registers wp-admin menu pages |
|
||||
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
|
||||
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
|
||||
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
|
||||
| `BlockPreview` | Static editor-preview markup for the blocks |
|
||||
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
|
||||
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
|
||||
| `Auth\LoginPage` | Renders front-end student login form |
|
||||
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
|
||||
| `Availability\AvailabilityRepository` | CRUD for availability slots |
|
||||
| `Availability\AvailabilityController` | Instructor availability management page |
|
||||
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
|
||||
| `Booking\Lesson` | Immutable value object for a lesson row |
|
||||
| `Booking\BookingRepository` | CRUD for lesson bookings |
|
||||
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
|
||||
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
|
||||
| `Booking\LessonController` | Admin and instructor lesson list pages |
|
||||
|
||||
### REST API Namespace
|
||||
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
|
||||
|
||||
### Testing Approach
|
||||
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
|
||||
|
||||
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
|
||||
|
||||
**Brain\Monkey API notes:**
|
||||
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
|
||||
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
|
||||
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
|
||||
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
|
||||
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
|
||||
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
|
||||
Tests stub WordPress with Brain\Monkey rather than booting a real WP install. The setup and the Brain\Monkey/Mockery API gotchas are in `tests/CLAUDE.md`.
|
||||
|
||||
### Adding a Feature
|
||||
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
|
||||
@@ -106,10 +29,3 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
|
||||
3. Add template(s) under `templates/` if needed.
|
||||
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
|
||||
5. Run `composer test` — all tests must pass before the feature is complete.
|
||||
|
||||
### CI
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
|
||||
- **lint** — PHPCS WordPress coding standards
|
||||
- **static-analysis** — PHPStan level 10
|
||||
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
|
||||
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
|
||||
|
||||
+537
-10
@@ -34,27 +34,137 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.us-my-lessons {
|
||||
/*
|
||||
* The upcoming-lessons panel. Every rule here is scoped under #us-booking-app —
|
||||
* the same id-level specificity .us-slot above uses — because these rows sit in
|
||||
* whatever layout the theme provides and carry more content than a calendar
|
||||
* cell. Bare class selectors lost to theme rules on div/span/strong, which
|
||||
* collapsed the flex layout and piled the details on top of the actions.
|
||||
*/
|
||||
#us-booking-app .us-my-lessons {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.us-my-lesson {
|
||||
#us-booking-app .us-my-lesson {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.us-my-lesson-actions {
|
||||
/*
|
||||
* `min-width: 0` lets the title column shrink below its content width — without
|
||||
* it a long offering title cannot compress and shoves the status pill and
|
||||
* Cancel button out of the row. The flex-basis keeps the details and the
|
||||
* actions on one line while there is room, and wraps them once there is not.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 14em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-title,
|
||||
#us-booking-app .us-my-lesson-when {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-duration {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-when {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-cancel-lesson {
|
||||
/*
|
||||
* Theme-proofing for the leaf text. The row and its two columns are divs with
|
||||
* explicit flex rules above, but the text itself still sits in inline elements
|
||||
* a theme is free to take out of normal flow — an absolutely positioned,
|
||||
* floated or negatively offset span drops the date/time on top of the title and
|
||||
* the status pill on top of the Cancel button. Pinning the properties that
|
||||
* would have to change keeps the leaves in flow, at the same id-level
|
||||
* specificity the rules above rely on.
|
||||
*
|
||||
* `line-height` is pinned for the same reason and is the subtler one, because a
|
||||
* theme does not have to target this panel to break it — it is inherited, so a
|
||||
* `line-height: 0` anywhere above (the usual icon-font or sprite reset) reaches
|
||||
* these elements untouched. Below 1 it produces both halves of the same bug: a
|
||||
* line box shorter than its glyphs, so stacked lines in the details column
|
||||
* overlap, and an inline-block pill whose background is shorter than the text
|
||||
* sitting in it. Nothing here should ever inherit a line-height, so the panel
|
||||
* states its own.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson,
|
||||
#us-booking-app .us-my-lesson-info,
|
||||
#us-booking-app .us-my-lesson-actions,
|
||||
#us-booking-app .us-my-lesson-title,
|
||||
#us-booking-app .us-my-lesson-when,
|
||||
#us-booking-app .us-my-lesson-duration,
|
||||
#us-booking-app .us-my-lesson-who,
|
||||
#us-booking-app .us-my-lesson-kind,
|
||||
#us-booking-app .us-lesson-status {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-title,
|
||||
#us-booking-app .us-my-lesson-when,
|
||||
#us-booking-app .us-my-lesson-duration,
|
||||
#us-booking-app .us-my-lesson-who,
|
||||
#us-booking-app .us-my-lesson-kind,
|
||||
#us-booking-app .us-lesson-status {
|
||||
position: static;
|
||||
float: none;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*
|
||||
* The rows the "Show all" button reveals. `[hidden]` is only a UA-stylesheet
|
||||
* rule, so any author rule setting a display on div beats it — the html5-reset
|
||||
* `div { display: block }` is still widespread in themes — and the rows the
|
||||
* button is meant to gate render anyway. An author !important is the only way
|
||||
* to win that cascade.
|
||||
*/
|
||||
#us-booking-app [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#us-booking-app .us-show-all-lessons {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#us-booking-app .us-show-all-lessons:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
#us-booking-app .us-cancel-lesson {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
@@ -63,32 +173,91 @@
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.us-cancel-lesson:hover {
|
||||
#us-booking-app .us-cancel-lesson:hover {
|
||||
border-color: #c00;
|
||||
}
|
||||
|
||||
.us-lesson-status {
|
||||
#us-booking-app .us-lesson-status {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: #eee;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.us-lesson-status-confirmed {
|
||||
#us-booking-app .us-lesson-status-confirmed {
|
||||
background: #e2f5e5;
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
.us-lesson-status-pending {
|
||||
#us-booking-app .us-lesson-status-pending {
|
||||
background: #fdf3d7;
|
||||
color: #8a6d1a;
|
||||
}
|
||||
|
||||
.us-calendar-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.us-filter-toggle {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-filter-toggle.us-active {
|
||||
background: #333;
|
||||
border-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.us-type-filter {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.us-type-filter-heading {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-type-filter-choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.us-type-filter-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.us-type-filter-clear {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-view-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.us-view-toggle button {
|
||||
@@ -152,6 +321,102 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The price and pay agreement on a booking / enrolment form. */
|
||||
.us-price {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-price h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.us-price p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.us-price-amount strong {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.us-price-cadence {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.us-price-tax,
|
||||
.us-price-note {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.us-price-agree {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The cadence-carrying price on a group-class card. */
|
||||
.us-class-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Policy acceptance — booking, enrolment, and signup all render this markup. */
|
||||
.us-policy {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-policy h4 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
/*
|
||||
* The body is admin-authored HTML sitting inside whatever layout the theme
|
||||
* provides, so it gets an explicit reading box rather than inheriting one.
|
||||
* `overflow-wrap` breaks pasted URLs instead of letting one long token force
|
||||
* the horizontal scrollbar, and the bounded height keeps a long policy from
|
||||
* pushing the accept checkbox off the screen.
|
||||
*/
|
||||
.us-policy-body {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.us-policy-body p,
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.us-policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.us-policy-accept,
|
||||
.us-policies input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.us-week-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -160,6 +425,268 @@
|
||||
.us-week-day {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A lesson row carries a title, a date/time, a status pill and a button —
|
||||
* more than fits one narrow line, so stack the details above the actions
|
||||
* rather than letting them wrap into each other.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-info {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* "Who is this for?" picker — booking and enrolment. Present only on an account
|
||||
* that books for more than one person, so it is styled as a normal field rather
|
||||
* than a callout.
|
||||
*/
|
||||
.us-student-picker select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Whose lesson a row in the upcoming panel is — only shown on an account that
|
||||
* books for more than one person. Scoped under #us-booking-app like the rest of
|
||||
* the panel; as a bare class it was the one rule in the group a theme could
|
||||
* outrank on a plain span.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson-who {
|
||||
font-weight: normal;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/*
|
||||
* Marks a row in the upcoming panel as a group-class session. The list mixes
|
||||
* one-to-one lessons and classes, and only the class rows have no Cancel button
|
||||
* — without a label that reads as a missing button rather than a different kind
|
||||
* of thing.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson-kind {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #eef1f5;
|
||||
color: #3c434a;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*
|
||||
* The signup form's grouped sections: who you are registering, your own
|
||||
* details, and the students you are adding. One box style for all three so the
|
||||
* form reads as a short list of decisions rather than an undifferentiated
|
||||
* column of fields.
|
||||
*/
|
||||
.us-reg-group {
|
||||
margin: 16px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.us-reg-group legend {
|
||||
padding: 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-children-intro {
|
||||
margin-top: 0;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/*
|
||||
* Each child is a bordered group so a family of three does not read as one long
|
||||
* undifferentiated column of fields.
|
||||
*/
|
||||
.us-child {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.us-child > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The guardian's manage-children screen ([us_family]). */
|
||||
|
||||
/*
|
||||
* The account holder's own details, set off from the students below so the two
|
||||
* halves of the page do not read as one long form.
|
||||
*/
|
||||
.us-family-self {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.us-family-self-email span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/*
|
||||
* Guidance under a control, not a label: it explains when a field matters
|
||||
* rather than naming it, so it is sized down and reads after the input.
|
||||
*/
|
||||
.us-field-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.us-family-list {
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.us-family-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.us-family-child-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-family-child-birth-year {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/*
|
||||
* The actions sit at the far end of the row. Remove is its own form (it posts),
|
||||
* so it is forced inline rather than taking a block of its own.
|
||||
*/
|
||||
.us-family-child-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.us-family-remove {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* The editing row replaces the child's line, so it spans the whole width. */
|
||||
.us-family-edit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* A name, a date and two actions do not fit one narrow line. */
|
||||
.us-family-child-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The live password verdict under the signup field. Colour is a reinforcement,
|
||||
* not the message — the text says what is wrong on its own, so this still reads
|
||||
* correctly to anyone who cannot separate the hues.
|
||||
*/
|
||||
.us-password-strength {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.us-password-strength.is-short,
|
||||
.us-password-strength.is-weak {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.us-password-strength.is-medium {
|
||||
color: #7a5c00;
|
||||
}
|
||||
|
||||
.us-password-strength.is-strong {
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
/*
|
||||
* The account panel: who is signed in, and the way out. Sized to sit in a
|
||||
* header or sidebar, so the rules stay minimal and inherit the theme's type —
|
||||
* a block that lands in a site header should look like it belongs there.
|
||||
*/
|
||||
.us-account p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.us-account-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-account-email {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.us-account-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
|
||||
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
|
||||
* author !important is the only way to win, and it has to sit before the
|
||||
* display rule it guards against.
|
||||
*/
|
||||
.us-notice[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
|
||||
* or class list rather than replacing it, so it needs to read as a banner
|
||||
* about something that just happened — not as the page's content.
|
||||
*/
|
||||
.us-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #b7dfc0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2faf4;
|
||||
color: #1a5c2a;
|
||||
}
|
||||
|
||||
.us-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.us-notice-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shown only in block-editor previews (see BlockPreview). */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Availability form: keep the lesson-length choices honest.
|
||||
*
|
||||
* A window is stored as consecutive lesson-length slots, so one shorter than the
|
||||
* chosen lesson length holds no slots at all and saves nothing. Picking 5:30–6:00
|
||||
* PM while the length select sat on its default of 60 minutes used to do exactly
|
||||
* that, silently. The server now rejects it with a message; this narrows the
|
||||
* choices first so the mistake is hard to make.
|
||||
*
|
||||
* This is a convenience only — AvailabilityController and the REST endpoint both
|
||||
* validate the same window server-side regardless of what happens here.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const form = document.getElementById('usc-add-availability');
|
||||
if (!form) return;
|
||||
|
||||
const startEl = document.getElementById('start_dt');
|
||||
const endEl = document.getElementById('end_dt');
|
||||
const durationEl = document.getElementById('duration_minutes');
|
||||
const warningEl = document.getElementById('usc-duration-warning');
|
||||
const submitEl = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||
|
||||
if (!startEl || !endEl || !durationEl) return;
|
||||
|
||||
/**
|
||||
* Minutes between the two datetime-local inputs, or 0 when the pair is not a
|
||||
* usable window yet — empty, unparseable, backwards, or spanning two days
|
||||
* (which the server rejects on its own terms, with its own message).
|
||||
*/
|
||||
function windowMinutes() {
|
||||
const start = new Date(startEl.value);
|
||||
const end = new Date(endEl.value);
|
||||
|
||||
if (!startEl.value || !endEl.value || isNaN(start) || isNaN(end)) return 0;
|
||||
if (end <= start) return 0;
|
||||
if (startEl.value.slice(0, 10) !== endEl.value.slice(0, 10)) return 0;
|
||||
|
||||
return Math.round((end - start) / 60000);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const minutes = windowMinutes();
|
||||
const options = Array.from(durationEl.options);
|
||||
|
||||
// No usable window yet: leave every choice alone rather than fighting
|
||||
// someone part-way through typing a date.
|
||||
if (minutes === 0) {
|
||||
options.forEach((option) => {
|
||||
option.hidden = false;
|
||||
option.disabled = false;
|
||||
});
|
||||
setBlocked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let fits = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
const tooLong = Number(option.value) > minutes;
|
||||
|
||||
option.hidden = tooLong;
|
||||
option.disabled = tooLong;
|
||||
|
||||
if (!tooLong) fits.push(option);
|
||||
});
|
||||
|
||||
if (fits.length === 0) {
|
||||
// Nothing bookable fits, so the form cannot produce a single slot.
|
||||
setBlocked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBlocked(false);
|
||||
|
||||
// The selection may have just been hidden — fall back to the longest
|
||||
// length that still fits, which is what the instructor most likely wants.
|
||||
if (durationEl.selectedOptions[0] && durationEl.selectedOptions[0].disabled) {
|
||||
durationEl.value = fits.reduce(
|
||||
(longest, option) => (Number(option.value) > Number(longest.value) ? option : longest),
|
||||
fits[0]
|
||||
).value;
|
||||
}
|
||||
}
|
||||
|
||||
function setBlocked(blocked) {
|
||||
if (warningEl) warningEl.hidden = !blocked;
|
||||
if (submitEl) submitEl.disabled = blocked;
|
||||
}
|
||||
|
||||
startEl.addEventListener('change', refresh);
|
||||
startEl.addEventListener('input', refresh);
|
||||
endEl.addEventListener('change', refresh);
|
||||
endEl.addEventListener('input', refresh);
|
||||
|
||||
refresh();
|
||||
}());
|
||||
+174
-30
@@ -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;
|
||||
@@ -83,6 +83,47 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown of active private-lesson types fetched from the plugin's public
|
||||
* offerings endpoint. Values are offering IDs; 0 means every type.
|
||||
*/
|
||||
function LessonTypeSelect(props) {
|
||||
const [offerings, setOfferings] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch({ path: '/us-scheduler/v1/offerings?kind=private_lesson' })
|
||||
.then(setOfferings)
|
||||
.catch(() => setOfferings([]));
|
||||
}, []);
|
||||
|
||||
const options = [{ label: __('All lesson types', 'unsupervised-schedular'), value: '0' }].concat(
|
||||
(offerings || []).map((o) => ({
|
||||
label: o.duration_minutes
|
||||
? `${o.title} (${o.duration_minutes} min)`
|
||||
: (o.title || __('(no title)', 'unsupervised-schedular')),
|
||||
value: String(o.id),
|
||||
}))
|
||||
);
|
||||
|
||||
// A previously chosen type that is no longer offered keeps its stored
|
||||
// id visible instead of silently pretending "All lesson types" is set.
|
||||
const value = String(props.value || 0);
|
||||
if (offerings !== null && !options.some((opt) => opt.value === value)) {
|
||||
options.push({
|
||||
label: __('Unavailable lesson type #', 'unsupervised-schedular') + value,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
|
||||
return el(SelectControl, {
|
||||
label: props.label,
|
||||
help: props.help,
|
||||
value: value,
|
||||
options: options,
|
||||
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
|
||||
});
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
name: 'us-scheduler/booking',
|
||||
@@ -94,24 +135,60 @@
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
autoRedirect: { type: 'boolean', default: false },
|
||||
lessonTypeId: { type: 'number', default: 0 },
|
||||
showTypeFilter: { type: 'boolean', default: true },
|
||||
displayMode: { type: 'string', default: 'both' },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
PanelBody,
|
||||
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
|
||||
el(PageSelect, {
|
||||
label: __('Login page', 'unsupervised-schedular'),
|
||||
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
}),
|
||||
el(ToggleControl, {
|
||||
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||||
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
||||
checked: !!attributes.autoRedirect,
|
||||
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||||
})
|
||||
),
|
||||
inspector: (attributes, setAttributes) => [
|
||||
el(
|
||||
PanelBody,
|
||||
{ title: __('What to show', 'unsupervised-schedular'), key: 'display' },
|
||||
el(SelectControl, {
|
||||
label: __('Sections', 'unsupervised-schedular'),
|
||||
help: __('Split the page in two: a booking calendar here, the student’s upcoming lessons somewhere else.', 'unsupervised-schedular'),
|
||||
value: attributes.displayMode || 'both',
|
||||
options: [
|
||||
{ label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' },
|
||||
{ label: __('Booking only', 'unsupervised-schedular'), value: 'booking' },
|
||||
{ label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' },
|
||||
],
|
||||
onChange: (displayMode) => setAttributes({ displayMode }),
|
||||
})
|
||||
),
|
||||
el(
|
||||
PanelBody,
|
||||
{ title: __('Lesson types', 'unsupervised-schedular'), key: 'lesson-types' },
|
||||
el(LessonTypeSelect, {
|
||||
label: __('Lesson type', 'unsupervised-schedular'),
|
||||
help: __('Show only the times bookable as one lesson type, for embedding on a page dedicated to it. That type is then the only one students can book here.', 'unsupervised-schedular'),
|
||||
value: attributes.lessonTypeId,
|
||||
onChange: (lessonTypeId) => setAttributes({ lessonTypeId }),
|
||||
}),
|
||||
el(ToggleControl, {
|
||||
label: __('Show the lesson-type filter', 'unsupervised-schedular'),
|
||||
help: __('Offer students the “Show Only” button that narrows the calendar to chosen lesson types. Not used when a single lesson type is set above.', 'unsupervised-schedular'),
|
||||
checked: attributes.showTypeFilter !== false,
|
||||
onChange: (showTypeFilter) => setAttributes({ showTypeFilter }),
|
||||
})
|
||||
),
|
||||
el(
|
||||
PanelBody,
|
||||
{ title: __('Logged-out visitors', 'unsupervised-schedular'), key: 'logged-out' },
|
||||
el(PageSelect, {
|
||||
label: __('Login page', 'unsupervised-schedular'),
|
||||
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
}),
|
||||
el(ToggleControl, {
|
||||
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||||
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
||||
checked: !!attributes.autoRedirect,
|
||||
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'us-scheduler/student-login',
|
||||
@@ -151,18 +228,38 @@
|
||||
shortcode: 'us_student_register',
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
autoRedirect: { type: 'boolean', default: false },
|
||||
inviteOnlyMessage: { type: 'string', default: '' },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
PanelBody,
|
||||
{ title: __('After email confirmation', 'unsupervised-schedular') },
|
||||
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'),
|
||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
})
|
||||
),
|
||||
inspector: (attributes, setAttributes) => [
|
||||
el(
|
||||
PanelBody,
|
||||
{ title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
|
||||
el(PageSelect, {
|
||||
label: __('Sign-in page', 'unsupervised-schedular'),
|
||||
help: __('Where students are sent once registration finishes — after they confirm their email address, or straight away for an invited student.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
}),
|
||||
el(ToggleControl, {
|
||||
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||||
help: __('Send students straight to that page instead of showing the link. Requires a page to be chosen; errors and the "check your email" step are never skipped.', 'unsupervised-schedular'),
|
||||
checked: !!attributes.autoRedirect,
|
||||
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||||
})
|
||||
),
|
||||
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,12 +276,59 @@
|
||||
{ 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 }),
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'us-scheduler/family',
|
||||
title: __('Profile', 'unsupervised-schedular'),
|
||||
description: __('Lets a parent or guardian add, edit and remove the students they book lessons for.', 'unsupervised-schedular'),
|
||||
icon: 'groups',
|
||||
// 'family' and 'children' are kept as search terms only — they are
|
||||
// never displayed, and the block answered to them before it was
|
||||
// renamed, so anyone reaching for the old word still finds it.
|
||||
keywords: ['profile', 'students', 'family', 'children', 'guardian', 'parent'],
|
||||
shortcode: 'us_family',
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
PanelBody,
|
||||
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
|
||||
el(PageSelect, {
|
||||
label: __('Login page', 'unsupervised-schedular'),
|
||||
help: __('Where visitors who are not signed in are sent to log in.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'us-scheduler/account',
|
||||
title: __('Account', 'unsupervised-schedular'),
|
||||
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
|
||||
icon: 'admin-users',
|
||||
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
|
||||
shortcode: 'us_account',
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
PanelBody,
|
||||
{ title: __('Signing in and out', 'unsupervised-schedular') },
|
||||
el(PageSelect, {
|
||||
label: __('Login page', 'unsupervised-schedular'),
|
||||
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
})
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
blocks.forEach((def) => {
|
||||
|
||||
+405
-67
@@ -11,6 +11,16 @@
|
||||
const errorBox = document.getElementById('us-booking-error');
|
||||
const { restUrl, nonce } = usScheduler;
|
||||
|
||||
// Per-instance options from the block/shortcode: pin the page to a single
|
||||
// lesson type, and whether the "Show Only" filter is offered at all.
|
||||
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
|
||||
const filterEnabled = app.dataset.typeFilter !== '0';
|
||||
|
||||
// Who this account may book for — children first, the account holder last,
|
||||
// so a guardian's default selection is a child rather than themselves. A
|
||||
// single-student account has one entry and gets no picker.
|
||||
const students = window.usGuardian.parseStudents(app.dataset.students);
|
||||
|
||||
function apiFetch(path, options = {}) {
|
||||
return fetch(restUrl + path, {
|
||||
...options,
|
||||
@@ -81,6 +91,45 @@
|
||||
let view = 'week';
|
||||
let weekStart = null;
|
||||
|
||||
// Every active private-lesson type the student may book, across instructors.
|
||||
let catalog = [];
|
||||
|
||||
// Lesson types the student has filtered the calendar down to; empty means
|
||||
// "no filter" — every open slot is shown. The list starts collapsed behind
|
||||
// the "Show Only" button and stays open across re-renders once revealed.
|
||||
const selectedTypeIds = new Set();
|
||||
let filterOpen = false;
|
||||
|
||||
// Whether an offering can be booked into a slot — the client-side mirror of
|
||||
// the rule `POST /bookings` enforces: a slot tied to an offering takes that
|
||||
// offering only, and a generic slot takes any of its instructor's types
|
||||
// whose length fits.
|
||||
function offeringFitsSlot(offering, slot) {
|
||||
if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false;
|
||||
|
||||
const tiedId = Number(slot.offering_id) || 0;
|
||||
if (tiedId) return Number(offering.id) === tiedId;
|
||||
|
||||
return !offering.duration_minutes
|
||||
|| Number(offering.duration_minutes) === Number(slot.duration_minutes);
|
||||
}
|
||||
|
||||
const filterActive = () => selectedTypeIds.size > 0;
|
||||
|
||||
const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id));
|
||||
|
||||
// The lesson types this slot could be booked as, honouring the filter.
|
||||
function slotChoices(slot) {
|
||||
return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o));
|
||||
}
|
||||
|
||||
// With a filter set, a slot is only shown when one of the chosen lesson
|
||||
// types can actually be booked into it.
|
||||
function visibleSlots() {
|
||||
if (!filterActive()) return allSlots;
|
||||
return allSlots.filter((slot) => slotChoices(slot).length > 0);
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
@@ -98,17 +147,67 @@
|
||||
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
|
||||
}
|
||||
|
||||
function toggleHtml() {
|
||||
// The calendar's control row: the view toggle, and the button that reveals
|
||||
// the lesson-type filter beneath it.
|
||||
function controlsHtml() {
|
||||
return `
|
||||
<div class="us-view-toggle" role="group" aria-label="Calendar view">
|
||||
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
|
||||
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
|
||||
<div class="us-calendar-controls">
|
||||
<div class="us-view-toggle" role="group" aria-label="Calendar view">
|
||||
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
|
||||
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
|
||||
</div>
|
||||
${filterToggleHtml()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Nothing to filter with a single bookable type, so the control only
|
||||
// appears once there is a choice to make.
|
||||
function filterToggleHtml() {
|
||||
if (!filterEnabled || catalog.length < 2) return '';
|
||||
|
||||
const count = filterActive() ? ` (${selectedTypeIds.size})` : '';
|
||||
|
||||
return `
|
||||
<button type="button" id="us-filter-toggle" class="us-filter-toggle${filterActive() ? ' us-active' : ''}"
|
||||
aria-expanded="${filterOpen}" aria-controls="us-type-filter">Show Only${count}</button>`;
|
||||
}
|
||||
|
||||
// "Piano Lesson (30 min)" — the instructor's name is only worth the space
|
||||
// when the catalog spans more than one of them.
|
||||
function filterLabel(offering) {
|
||||
const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : '';
|
||||
const instructors = new Set(catalog.map((o) => Number(o.instructor_id)));
|
||||
const who = instructors.size > 1 && offering.instructor_name
|
||||
? ` — ${offering.instructor_name}`
|
||||
: '';
|
||||
return `${offering.title}${duration}${who}`;
|
||||
}
|
||||
|
||||
// The lesson-type list itself — collapsed until the student opens it, and
|
||||
// rendered between the control row and the calendar.
|
||||
function filterHtml() {
|
||||
if (!filterEnabled || catalog.length < 2 || !filterOpen) return '';
|
||||
|
||||
const choices = catalog.map((o) => `
|
||||
<label class="us-type-filter-choice">
|
||||
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
|
||||
${escHtml(filterLabel(o))}
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="us-type-filter" id="us-type-filter" role="group" aria-label="Filter by lesson type">
|
||||
<span class="us-type-filter-heading">Lesson type</span>
|
||||
<div class="us-type-filter-choices">
|
||||
${choices}
|
||||
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Agenda-style calendar: available slots grouped by day.
|
||||
function listHtml() {
|
||||
return groupByDay(allSlots).map(([key, daySlots]) => `
|
||||
function listHtml(slots) {
|
||||
return groupByDay(slots).map(([key, daySlots]) => `
|
||||
<div class="us-day">
|
||||
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
||||
${daySlots.map((slot) => `
|
||||
@@ -122,8 +221,8 @@
|
||||
}
|
||||
|
||||
// Weekly calendar: seven day columns with a bookable button per slot.
|
||||
function weekHtml() {
|
||||
const byDay = new Map(groupByDay(allSlots));
|
||||
function weekHtml(slots) {
|
||||
const byDay = new Map(groupByDay(slots));
|
||||
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
|
||||
|
||||
const columns = days.map((key) => {
|
||||
@@ -151,20 +250,41 @@
|
||||
}
|
||||
|
||||
function render() {
|
||||
const slots = visibleSlots();
|
||||
|
||||
// The pinned lesson type is no longer on offer (deactivated or
|
||||
// deleted), so this page has nothing it is allowed to book.
|
||||
if (pinnedTypeId && !catalog.length) {
|
||||
slotList.innerHTML = '<p>This lesson type is not available for booking right now.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing open at all: there is nothing for the controls to act on.
|
||||
if (!allSlots.length) {
|
||||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Anchor the week view to the week of the earliest open slot (the API
|
||||
// returns slots ordered by start), so the first look is never empty.
|
||||
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
|
||||
if (!slots.length) {
|
||||
const message = pinnedTypeId
|
||||
? '<p>No open times for this lesson type right now.</p>'
|
||||
: '<p>No open times match the selected lesson types.</p>';
|
||||
|
||||
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
|
||||
slotList.innerHTML = controlsHtml() + filterHtml() + message;
|
||||
wireControlEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// Anchor the week view to the week of the earliest matching slot (the
|
||||
// API returns slots ordered by start), so the first look is never empty.
|
||||
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
|
||||
|
||||
slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
|
||||
wireControlEvents();
|
||||
wireCalendarEvents();
|
||||
}
|
||||
|
||||
function wireCalendarEvents() {
|
||||
function wireControlEvents() {
|
||||
document.getElementById('us-view-list').addEventListener('click', () => {
|
||||
view = 'list';
|
||||
render();
|
||||
@@ -174,6 +294,40 @@
|
||||
render();
|
||||
});
|
||||
|
||||
const toggle = document.getElementById('us-filter-toggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', () => {
|
||||
filterOpen = !filterOpen;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
const id = Number(input.value);
|
||||
if (input.checked) {
|
||||
selectedTypeIds.add(id);
|
||||
} else {
|
||||
selectedTypeIds.delete(id);
|
||||
}
|
||||
// The nearest matching time may be weeks away, so re-anchor the
|
||||
// week view instead of leaving the student on an empty week.
|
||||
weekStart = null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
const clear = document.getElementById('us-type-filter-clear');
|
||||
if (clear) {
|
||||
clear.addEventListener('click', () => {
|
||||
selectedTypeIds.clear();
|
||||
weekStart = null;
|
||||
render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wireCalendarEvents() {
|
||||
const prev = document.getElementById('us-week-prev');
|
||||
const next = document.getElementById('us-week-next');
|
||||
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
|
||||
@@ -181,7 +335,12 @@
|
||||
|
||||
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
||||
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
||||
if (slot) btn.addEventListener('click', () => openRegistration(slot));
|
||||
if (slot) {
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openRegistration(slot);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,39 +370,34 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Active private-lesson offerings per instructor, so revisiting the
|
||||
// registration form does not refetch the same catalog.
|
||||
const offeringCache = new Map();
|
||||
|
||||
function instructorOfferings(instructorId) {
|
||||
if (offeringCache.has(instructorId)) {
|
||||
return Promise.resolve(offeringCache.get(instructorId));
|
||||
}
|
||||
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
|
||||
offeringCache.set(instructorId, list);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
||||
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||||
function offeringLabel(o) {
|
||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||
const price = Number(o.price) > 0
|
||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
||||
: 'Free';
|
||||
return `${o.title} (${duration}${price})`;
|
||||
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||||
}
|
||||
|
||||
// How many lessons a weekly reservation can claim, mirroring
|
||||
// BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never
|
||||
// higher than the server will actually charge for.
|
||||
const MAX_WEEKLY_OCCURRENCES = 12;
|
||||
|
||||
// The open times a weekly reservation of this slot would claim: every
|
||||
// still-unbooked slot of its recurring group, capped the way the server
|
||||
// caps it. Some may be taken by another student first, so this is the
|
||||
// upper bound on what will be booked, not a guarantee.
|
||||
function weeklyOccurrences(slot) {
|
||||
if (!slot.recurrence_group) return 1;
|
||||
|
||||
const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length;
|
||||
|
||||
return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES);
|
||||
}
|
||||
|
||||
function openRegistration(slot) {
|
||||
clearError();
|
||||
|
||||
Promise.all([
|
||||
instructorOfferings(Number(slot.instructor_id)),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([offerings, policies]) => {
|
||||
renderRegistration(slot, offerings, policies);
|
||||
})
|
||||
apiFetch('policies?scope=booking')
|
||||
.then((policies) => renderRegistration(slot, policies))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
@@ -258,6 +412,19 @@
|
||||
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
// Only one type is left to book this slot as — usually because the
|
||||
// filter narrowed it down — so it is chosen for the student.
|
||||
if (choices.length === 1) {
|
||||
return `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
<select id="us-offering" required>
|
||||
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
|
||||
</select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
@@ -268,14 +435,13 @@
|
||||
</p>`;
|
||||
}
|
||||
|
||||
function renderRegistration(slot, offerings, policies) {
|
||||
function renderRegistration(slot, policies) {
|
||||
const tiedId = Number(slot.offering_id) || 0;
|
||||
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
|
||||
const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null;
|
||||
|
||||
// Generic slots offer every lesson type that fits the slot's length.
|
||||
const choices = tiedId
|
||||
? []
|
||||
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
|
||||
// Generic slots offer every lesson type that fits the slot — narrowed to
|
||||
// the filtered types when the student has set a filter.
|
||||
const choices = tiedId ? [] : slotChoices(slot);
|
||||
|
||||
if (!tiedId && !choices.length) {
|
||||
// The server rejects offering-less bookings, so without a matching
|
||||
@@ -297,10 +463,12 @@
|
||||
<div class="us-register">
|
||||
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}</h3>
|
||||
<form id="us-register-form">
|
||||
${window.usGuardian.selectorHtml(students, 'us-booking-student')}
|
||||
${offeringFieldHtml(tied, tiedId, choices)}
|
||||
<div id="us-questions"></div>
|
||||
${policies.map(policyField).join('')}
|
||||
${weekly}
|
||||
<div id="us-price-summary"></div>
|
||||
<p>
|
||||
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
||||
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -309,11 +477,34 @@
|
||||
</div>`;
|
||||
|
||||
// The intake questions belong to the selected offering, so they follow
|
||||
// the picker instead of being fixed at render time.
|
||||
let selectedId = tiedId;
|
||||
// the picker instead of being fixed at render time. A tied slot — or a
|
||||
// lone remaining type — is already decided, so its questions load
|
||||
// straight away.
|
||||
let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0);
|
||||
let questions = [];
|
||||
|
||||
const questionsBox = document.getElementById('us-questions');
|
||||
const priceBox = document.getElementById('us-price-summary');
|
||||
const weeklyEl = document.getElementById('us-weekly');
|
||||
|
||||
// What the booking will cost and the agreement to pay it, restated
|
||||
// whenever the choices that decide the amount change: the lesson type
|
||||
// carries the price, and a weekly reservation multiplies a per-lesson
|
||||
// one-time price by every week it claims. A slot tied to a type the
|
||||
// catalog no longer carries has no price to quote, so it shows nothing
|
||||
// rather than a figure it cannot stand behind.
|
||||
function renderPrice() {
|
||||
const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null;
|
||||
priceBox.innerHTML = offering
|
||||
? window.usPricing.summaryHtml({
|
||||
price: offering.price,
|
||||
currency: offering.currency,
|
||||
billing_mode: offering.billing_mode,
|
||||
kind: offering.kind,
|
||||
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
|
||||
})
|
||||
: '';
|
||||
}
|
||||
|
||||
function loadQuestions() {
|
||||
questions = [];
|
||||
@@ -331,10 +522,14 @@
|
||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||
selectedId = Number(e.target.value) || 0;
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
});
|
||||
}
|
||||
|
||||
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||||
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
|
||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||
@@ -343,6 +538,10 @@
|
||||
showError('Please choose a lesson type.');
|
||||
return;
|
||||
}
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitBooking(e.target, slot, selectedId, questions);
|
||||
});
|
||||
}
|
||||
@@ -365,6 +564,7 @@
|
||||
body: JSON.stringify({
|
||||
slot_id: slot.id,
|
||||
offering_id: offeringId,
|
||||
student_id: window.usGuardian.selectedId('us-booking-student'),
|
||||
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
|
||||
answers,
|
||||
accepted_policy_version_ids: accepted,
|
||||
@@ -376,8 +576,11 @@
|
||||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||||
: null))
|
||||
.then((result) => {
|
||||
loadMyLessons();
|
||||
showConfirmation(window.usPayment.message(result));
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadSlots() clears any standing notice, and it
|
||||
// is what puts the calendar back with the booked slot gone.
|
||||
return loadSlots().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
@@ -385,9 +588,68 @@
|
||||
function lessonStatusLabel(status) {
|
||||
if (status === 'pending') return 'Pending payment';
|
||||
if (status === 'confirmed') return 'Confirmed';
|
||||
// A group-class session carries its enrolment's status, and "active"
|
||||
// reads as jargon next to "Confirmed".
|
||||
if (status === 'active') return 'Enrolled';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// How many upcoming lessons to show before the "Show all" reveal.
|
||||
const INITIAL_LESSON_COUNT = 5;
|
||||
|
||||
// Whose lesson this is. Only shown on an account that books for more than
|
||||
// one person — on a single-student account the name is on every row and says
|
||||
// nothing.
|
||||
function lessonWhoHtml(l) {
|
||||
if (students.length < 2 || !l.student_name) return '';
|
||||
|
||||
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
|
||||
}
|
||||
|
||||
// A group-class session is a date in a term, not a booked slot: there is no
|
||||
// lesson to cancel and no time to release, so it carries no Cancel button.
|
||||
// Withdrawing from the class is a separate decision, made on the class page.
|
||||
function isGroupSession(l) {
|
||||
return l.kind === 'group_class';
|
||||
}
|
||||
|
||||
// When a row meets. A class with no class time set has no clock to put it on,
|
||||
// so it carries `schedule` — the studio's own wording, or its term dates — and
|
||||
// that is shown verbatim in place of a date and time. A dated row with no
|
||||
// duration knows when it starts but not when it ends, and says only that
|
||||
// rather than inventing a finish.
|
||||
function lessonWhenHtml(l) {
|
||||
if (l.schedule) {
|
||||
return escHtml(String(l.schedule));
|
||||
}
|
||||
|
||||
const when = `${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}`;
|
||||
|
||||
return l.end_dt ? `${when}–${escHtml(timeOf(l.end_dt))}` : when;
|
||||
}
|
||||
|
||||
function lessonRowHtml(l) {
|
||||
const group = isGroupSession(l);
|
||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : (group ? 'Group class' : 'Lesson');
|
||||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||
const badge = group ? ' <span class="us-my-lesson-kind">Group class</span>' : '';
|
||||
const action = group ? '' : `<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>`;
|
||||
// The two columns are divs, not spans: as spans the layout only held up
|
||||
// while the stylesheet's display:flex won, and a theme rule on span
|
||||
// collapsed the row onto itself.
|
||||
return `
|
||||
<div class="us-my-lesson">
|
||||
<div class="us-my-lesson-info">
|
||||
<strong class="us-my-lesson-title">${title}${duration}${badge}${lessonWhoHtml(l)}</strong>
|
||||
<span class="us-my-lesson-when">${lessonWhenHtml(l)}</span>
|
||||
</div>
|
||||
<div class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
${action}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMyLessons(lessons) {
|
||||
const upcoming = lessons.filter((l) => l.start_dt);
|
||||
if (!upcoming.length) {
|
||||
@@ -395,20 +657,36 @@
|
||||
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);
|
||||
|
||||
// Named for what the list actually holds now that group-class sessions
|
||||
// sit in it alongside booked lessons.
|
||||
const heading = upcoming.some(isGroupSession)
|
||||
? 'Your upcoming lessons and classes'
|
||||
: 'Your upcoming lessons';
|
||||
|
||||
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('')}
|
||||
<h3>${heading}</h3>
|
||||
${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}</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)));
|
||||
});
|
||||
@@ -432,19 +710,79 @@
|
||||
.catch(() => { myLessons.innerHTML = ''; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed booking without taking the calendar away.
|
||||
*
|
||||
* This used to hide the slot list and leave the confirmation as the whole
|
||||
* page, which is a dead end: the student had nothing to click and no way
|
||||
* back to booking short of reloading. The notice now sits above a freshly
|
||||
* loaded calendar, so "it worked" and "you can book again" are the same
|
||||
* screen.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
slotList.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
if (!confirm) return;
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
// The private-lesson catalog drives both the filter and the registration
|
||||
// form's lesson-type picker, and it does not change while the student
|
||||
// browses — so it is fetched once and kept.
|
||||
let catalogLoaded = false;
|
||||
|
||||
function loadCatalog() {
|
||||
if (catalogLoaded) return Promise.resolve(catalog);
|
||||
return apiFetch('offerings?kind=private_lesson').then((list) => {
|
||||
// A pinned lesson type is the only one this page may book, so the
|
||||
// catalog is narrowed to it and the filter is fixed on it. With a
|
||||
// single type left the "Show Only" control hides itself.
|
||||
catalog = pinnedTypeId
|
||||
? list.filter((o) => Number(o.id) === pinnedTypeId)
|
||||
: list;
|
||||
|
||||
if (pinnedTypeId) selectedTypeIds.add(pinnedTypeId);
|
||||
|
||||
catalogLoaded = true;
|
||||
return catalog;
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the calendar is back. */
|
||||
function loadSlots() {
|
||||
clearError();
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
loadMyLessons();
|
||||
apiFetch('availability')
|
||||
.then((slots) => {
|
||||
|
||||
// An upcoming-lessons-only embed has no calendar to fill.
|
||||
if (!slotList) return Promise.resolve();
|
||||
|
||||
hideConfirmation();
|
||||
|
||||
return Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
.then(([slots]) => {
|
||||
allSlots = slots;
|
||||
render();
|
||||
})
|
||||
|
||||
+211
-34
@@ -12,8 +12,15 @@
|
||||
|
||||
// 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);
|
||||
|
||||
// Who this account may enrol — children first, the account holder last, so a
|
||||
// guardian's default selection is a child. One entry means no picker.
|
||||
const students = window.usGuardian.parseStudents(app.dataset.students);
|
||||
|
||||
function apiFetch(path, options = {}) {
|
||||
return fetch(restUrl + path, {
|
||||
...options,
|
||||
@@ -103,7 +110,105 @@
|
||||
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledOfferingIds) {
|
||||
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
|
||||
// comparison against the class's Y-m-d enrolment deadline.
|
||||
function todayYmd() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The effective enrolment deadline: the instructor's set deadline, or the
|
||||
// first class day by default. Empty when the class has no dates at all.
|
||||
function enrolmentDeadline(o) {
|
||||
return o.enrollment_deadline || o.term_start || '';
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day. Mirrors the server-side
|
||||
// Offering::isEnrollmentOpen() gate.
|
||||
function isEnrollmentOpen(o) {
|
||||
const deadline = enrolmentDeadline(o);
|
||||
return !deadline || todayYmd() <= deadline;
|
||||
}
|
||||
|
||||
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
|
||||
// enrolment there is no implicit default: an unset deadline keeps withdrawal
|
||||
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
|
||||
function isWithdrawalOpen(o) {
|
||||
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
|
||||
}
|
||||
|
||||
// Active enrolments grouped by class. A household can hold several in the
|
||||
// same class — one per student — so the value is a list, never a single id.
|
||||
function activeByOffering(enrollments) {
|
||||
const map = new Map();
|
||||
enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.forEach((e) => {
|
||||
const key = Number(e.offering_id);
|
||||
const held = map.get(key) || [];
|
||||
held.push({ id: e.id, studentId: Number(e.student_id) });
|
||||
map.set(key, held);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
// Who on this account could still be enrolled in a class: everyone the
|
||||
// account may enrol, minus those already holding an active enrolment in it.
|
||||
// The per-student check is the point — the account used to be treated as a
|
||||
// single enrollee, so enrolling one child hid the Enrol button from the rest
|
||||
// of the household even though the server would have taken them happily.
|
||||
function availableStudents(offeringId, enrolled) {
|
||||
const held = enrolled.get(Number(offeringId)) || [];
|
||||
|
||||
// Degraded case: an unparseable student list leaves no id to compare
|
||||
// against, so any existing enrolment is read as covering the account.
|
||||
if (!students.length) return held.length ? [] : [{ id: 0, name: '', is_self: true }];
|
||||
|
||||
const taken = new Set(held.map((e) => e.studentId));
|
||||
return students.filter((s) => !taken.has(Number(s.id)));
|
||||
}
|
||||
|
||||
// The enrolled student's name, or '' when there is nobody to tell them apart
|
||||
// from: an account with a single student reads better in the second person.
|
||||
function studentName(studentId) {
|
||||
if (students.length < 2) return '';
|
||||
const s = students.find((st) => Number(st.id) === Number(studentId));
|
||||
return s && !s.is_self ? s.name : '';
|
||||
}
|
||||
|
||||
function enrolledRow(o, e) {
|
||||
const name = studentName(e.studentId);
|
||||
return `
|
||||
<p class="us-enrolled"><strong>${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.</strong></p>
|
||||
${isWithdrawalOpen(o)
|
||||
? `<button data-enrollment-id="${e.id}" data-student="${escHtml(name)}" class="us-withdraw-btn">Withdraw${name ? ` ${escHtml(name)}` : ''}</button>`
|
||||
: `<p class="us-withdraw-closed">Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.</p>`}`;
|
||||
}
|
||||
|
||||
function classCard(o, enrolled) {
|
||||
const held = enrolled.get(Number(o.id)) || [];
|
||||
const available = availableStudents(o.id, enrolled);
|
||||
const canEnrol = available.length > 0 && isEnrollmentOpen(o);
|
||||
|
||||
return `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${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>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||
${canEnrol && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${held.map((e) => enrolledRow(o, e)).join('')}
|
||||
${canEnrol
|
||||
? `<button data-offering-id="${o.id}" class="us-enrol-btn">${held.length ? 'Enrol another student' : 'Enrol'}</button>`
|
||||
: (available.length ? '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>' : '')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolled) {
|
||||
let groups = offerings.filter((o) => o.kind === 'group_class');
|
||||
if (singleOfferingId) {
|
||||
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
|
||||
@@ -115,43 +220,78 @@
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = groups.map((o) => `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${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>` : ''}
|
||||
<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>`}
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = groups.map((o) => classCard(o, enrolled)).join('');
|
||||
|
||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
btn.addEventListener('click', () => openEnrolment(offering));
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openEnrolment(offering, availableStudents(offering.id, enrolled));
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function openEnrolment(offering) {
|
||||
function withdraw(enrollmentId, studentName) {
|
||||
clearError();
|
||||
// Named, because a household can hold more than one enrolment in the
|
||||
// same class and "this class" alone would not say whose seat is going.
|
||||
const prompt = studentName
|
||||
? `Withdraw ${studentName} from this class? Their seat is released and any pending payment is cancelled.`
|
||||
: 'Withdraw from this class? Your seat is released and any pending payment is cancelled.';
|
||||
if (!window.confirm(prompt)) {
|
||||
return;
|
||||
}
|
||||
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
|
||||
.then(loadClasses)
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function openEnrolment(offering, available) {
|
||||
clearError();
|
||||
Promise.all([
|
||||
apiFetch(`offerings/${offering.id}/questions`),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
|
||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function renderEnrolment(offering, questions, policies) {
|
||||
/**
|
||||
* The "who is this for?" control for one class, offering only the students
|
||||
* who are not already enrolled in it.
|
||||
*
|
||||
* When exactly one is left there is nothing to choose, but the id still has
|
||||
* to reach the server: an omitted picker posts no student_id, which the
|
||||
* server reads as "enrol the account holder" — and would enrol the parent
|
||||
* instead of the one child still to be signed up.
|
||||
*/
|
||||
function studentFieldHtml(available) {
|
||||
if (available.length > 1) {
|
||||
return window.usGuardian.selectorHtml(available, 'us-enrol-student');
|
||||
}
|
||||
|
||||
const only = available[0];
|
||||
if (!only) return '';
|
||||
|
||||
return `<input type="hidden" id="us-enrol-student" value="${Number(only.id)}">
|
||||
${students.length > 1
|
||||
? `<p class="us-student-picker">For ${only.is_self ? 'yourself' : escHtml(only.name)}.</p>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
function renderEnrolment(offering, questions, policies, available) {
|
||||
list.innerHTML = `
|
||||
<div class="us-register">
|
||||
<h3>${escHtml(offering.title)}</h3>
|
||||
<form id="us-enrol-form">
|
||||
${studentFieldHtml(available)}
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
${window.usPricing.summaryHtml(offering)}
|
||||
<p>
|
||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -162,6 +302,10 @@
|
||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitEnrolment(e.target, offering, questions);
|
||||
});
|
||||
}
|
||||
@@ -182,6 +326,7 @@
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
offering_id: offering.id,
|
||||
student_id: window.usGuardian.selectedId('us-enrol-student'),
|
||||
answers,
|
||||
accepted_policy_version_ids: accepted,
|
||||
}),
|
||||
@@ -191,34 +336,66 @@
|
||||
.then((res) => (res.payment
|
||||
? window.usPayment.collect('enrollment', res.id, list)
|
||||
: null))
|
||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||
.then((result) => {
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadClasses() clears any standing notice, and
|
||||
// it is what puts the list back showing the new enrolment.
|
||||
return loadClasses().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed enrolment without taking the class list away. Hiding
|
||||
* the list left the student on a dead-end screen with no way back to
|
||||
* browsing short of a reload; the notice now sits above a freshly loaded
|
||||
* list instead. Mirrors booking.js.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
list.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the list is back. */
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
list.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
// The student's own enrolments are fetched alongside the catalog so a
|
||||
// class they already have an active enrolment in shows its status
|
||||
hideConfirmation();
|
||||
// The household's enrolments are fetched alongside the catalog so a
|
||||
// class a student already has an active enrolment in shows their status
|
||||
// instead of offering to enrol them again (the API would reject the
|
||||
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
||||
Promise.all([
|
||||
// duplicate anyway). Each student is tracked separately: one child being
|
||||
// enrolled says nothing about their siblings, who can still be signed up
|
||||
// for the same class. A cancelled enrolment does not block re-enrolling.
|
||||
return Promise.all([
|
||||
apiFetch('offerings?kind=group_class'),
|
||||
apiFetch('enrollments'),
|
||||
])
|
||||
.then(([offerings, enrollments]) => renderClasses(
|
||||
offerings,
|
||||
new Set(enrollments
|
||||
.filter((e) => e.status === 'active')
|
||||
.map((e) => Number(e.offering_id)))
|
||||
))
|
||||
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* "Who is this for?" picker, shared by the lesson-booking and group-class
|
||||
* registration forms.
|
||||
*
|
||||
* The list arrives from the server already ordered children-first, with the
|
||||
* account holder last, and this module preserves that order: a guardian's
|
||||
* default selection is their first child, never themselves. Booking for the
|
||||
* wrong child is a correctable mistake; quietly enrolling the parent in a class
|
||||
* meant for their kid is not.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the server-rendered student list off a `data-students` attribute.
|
||||
* Anything unparseable degrades to an empty list, which renders no picker
|
||||
* and books for the signed-in user — the pre-guardian behaviour.
|
||||
*/
|
||||
function parseStudents(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw);
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's markup, or an empty string when there is nothing to choose:
|
||||
* an account with only itself on the list never sees the question.
|
||||
*/
|
||||
function selectorHtml(students, id) {
|
||||
if (!students || students.length < 2) return '';
|
||||
|
||||
const options = students.map((s) => {
|
||||
// The account holder reads as "Myself" — their own name next to their
|
||||
// children's is ambiguous about which row is the parent.
|
||||
const label = s.is_self ? `Myself (${s.name})` : s.name;
|
||||
return `<option value="${Number(s.id)}">${escHtml(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<p class="us-student-picker">
|
||||
<label for="${id}">Who is this for?<br>
|
||||
<select id="${id}" required>${options}</select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen student id, or 0 when no picker was rendered — the server
|
||||
* reads 0 as "the caller books for themselves".
|
||||
*/
|
||||
function selectedId(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? Number(el.value) || 0 : 0;
|
||||
}
|
||||
|
||||
window.usGuardian = { parseStudents, selectorHtml, selectedId };
|
||||
}());
|
||||
@@ -0,0 +1,170 @@
|
||||
/* global usScheduler */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Cadence wording for each offering billing mode, in the phrasing a student
|
||||
// sees beside a price. Mirrors Offering::VALID_BILLING_MODES.
|
||||
const CADENCE = {
|
||||
one_time: 'at booking',
|
||||
full_term: 'up front',
|
||||
weekly: 'weekly',
|
||||
monthly: 'monthly',
|
||||
};
|
||||
|
||||
// How each cadence is actually collected, spelled out beneath the price so
|
||||
// the one-word cadence is never the only thing a student has to go on.
|
||||
const CADENCE_NOTE = {
|
||||
one_time: 'Charged once, when you book.',
|
||||
full_term: 'Charged once, up front, for the whole term.',
|
||||
weekly: 'Charged for each lesson, 24 hours before it starts.',
|
||||
monthly: 'Charged on the 1st of each month, for that month’s lessons.',
|
||||
};
|
||||
|
||||
// The billing modes whose price is a per-lesson fee billed again and again,
|
||||
// rather than a single charge. Mirrors Offering::SCHEDULED_BILLING_MODES.
|
||||
const RECURRING = ['weekly', 'monthly'];
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function mode(billingMode) {
|
||||
return CADENCE[billingMode] ? billingMode : 'one_time';
|
||||
}
|
||||
|
||||
// A monthly charge rolls up every lesson that falls in the month, so a
|
||||
// private lesson's monthly price is quoted *per lesson* — the fee is
|
||||
// multiplied by the lessons booked that month. A group class is enrolled in
|
||||
// once, as one schedule, so its monthly figure is quoted as it stands.
|
||||
function isPerLessonMonthly(billingMode, kind) {
|
||||
return 'monthly' === billingMode && 'group_class' !== kind;
|
||||
}
|
||||
|
||||
// "50.00 CAD" — amount then currency code, the format used throughout the
|
||||
// ledger, receipts and payment notices.
|
||||
function money(amount, currency) {
|
||||
return `${(Number(amount) || 0).toFixed(2)} ${String(currency || '')}`.trim();
|
||||
}
|
||||
|
||||
// The studio's HST rate as a percentage, frozen onto every payment at
|
||||
// booking time (comped students are the one exception — they are not taxed).
|
||||
function taxRate() {
|
||||
return Number(usScheduler.taxRate) || 0;
|
||||
}
|
||||
|
||||
// Tax on a pre-tax amount, rounded the same way PaymentService does.
|
||||
function tax(amount) {
|
||||
return Math.round((Number(amount) || 0) * taxRate()) / 100;
|
||||
}
|
||||
|
||||
function total(amount) {
|
||||
return (Number(amount) || 0) + tax(amount);
|
||||
}
|
||||
|
||||
// "50.00 CAD at booking" / "50.00 CAD per lesson monthly" / "Free" — the
|
||||
// catalogue label, always carrying the cadence so a price is never shown
|
||||
// without saying when it is due.
|
||||
function priceLabel(offering) {
|
||||
const price = Number(offering.price) || 0;
|
||||
if (price <= 0) {
|
||||
return 'Free';
|
||||
}
|
||||
|
||||
const billingMode = mode(offering.billing_mode);
|
||||
const perLesson = isPerLessonMonthly(billingMode, offering.kind) ? 'per lesson ' : '';
|
||||
|
||||
return `${money(price, offering.currency)} ${perLesson}${CADENCE[billingMode]}`;
|
||||
}
|
||||
|
||||
// The price block shown on a booking/enrolment form, followed by the
|
||||
// agreement the student must tick to confirm they will pay it. A free
|
||||
// offering has nothing to agree to, so it renders nothing at all.
|
||||
//
|
||||
// opts: { price, currency, billing_mode, kind, occurrences }
|
||||
// `occurrences` is how many lessons a one-time price is charged for in this
|
||||
// one registration (a weekly reservation claims several at once); it is
|
||||
// ignored for the other modes, whose price is charged per period regardless.
|
||||
function summaryHtml(opts) {
|
||||
const price = Number(opts.price) || 0;
|
||||
if (price <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const billingMode = mode(opts.billing_mode);
|
||||
const currency = opts.currency;
|
||||
const each = total(price);
|
||||
const count = 'one_time' === billingMode ? Math.max(1, Number(opts.occurrences) || 1) : 1;
|
||||
|
||||
const taxLine = taxRate() > 0
|
||||
? `<p class="us-price-tax">${escHtml(`Plus ${taxRate()}% HST — ${money(each, currency)}${count > 1 ? ' per lesson' : ''}.`)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="us-price">
|
||||
<h4>Price</h4>
|
||||
<p class="us-price-amount">
|
||||
<strong>${escHtml(money(price, currency))}</strong>
|
||||
<span class="us-price-cadence">${escHtml(cadenceLabel(billingMode, opts.kind))}</span>
|
||||
</p>
|
||||
${taxLine}
|
||||
<p class="us-price-note">${escHtml(count > 1
|
||||
? 'Charged once, when you book — for every week reserved.'
|
||||
: CADENCE_NOTE[billingMode])}</p>
|
||||
<label class="us-price-agree">
|
||||
<input type="checkbox" class="us-price-accept" required>
|
||||
${escHtml(agreeText(each, currency, billingMode, count, opts.kind))}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// The cadence as it reads beside an amount: a private lesson billed monthly
|
||||
// adds "per lesson", since the month's charge is that fee times the lessons
|
||||
// it covers.
|
||||
function cadenceLabel(billingMode, kind) {
|
||||
return isPerLessonMonthly(billingMode, kind)
|
||||
? `per lesson ${CADENCE[billingMode]}`
|
||||
: CADENCE[billingMode];
|
||||
}
|
||||
|
||||
// What the student is ticking: the amount actually billed (tax included),
|
||||
// and when. A weekly reservation is charged per lesson for every week it
|
||||
// claims, and the claim can come up short when another student takes one of
|
||||
// the times first — so its total is stated as a ceiling, never a promise.
|
||||
function agreeText(each, currency, billingMode, count, kind) {
|
||||
if (RECURRING.indexOf(billingMode) !== -1) {
|
||||
// A monthly group class is enrolled in once and quoted as it stands;
|
||||
// everything else recurring is a per-lesson fee.
|
||||
return 'monthly' === billingMode && !isPerLessonMonthly(billingMode, kind)
|
||||
? `I agree to pay ${money(each, currency)} monthly.`
|
||||
: `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
if (count > 1) {
|
||||
return `I agree to pay ${money(each, currency)} per lesson at booking — `
|
||||
+ `up to ${count} lessons, ${money(each * count, currency)} in total.`;
|
||||
}
|
||||
|
||||
return `I agree to pay ${money(each, currency)} ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
// Whether the payment agreement has been ticked. A form without one (a free
|
||||
// offering) has nothing outstanding, so it counts as agreed.
|
||||
function agreed(root) {
|
||||
const box = root.querySelector('.us-price-accept');
|
||||
|
||||
return !box || box.checked;
|
||||
}
|
||||
|
||||
// Shared by the booking and group-class flows so a price reads the same
|
||||
// wherever a student meets it.
|
||||
window.usPricing = {
|
||||
priceLabel,
|
||||
summaryHtml,
|
||||
agreed,
|
||||
AGREE_REQUIRED: 'Please confirm you agree to pay the amount shown.',
|
||||
};
|
||||
}());
|
||||
+233
-32
@@ -1,57 +1,258 @@
|
||||
/**
|
||||
* Progressive enhancement for the two-step student registration form.
|
||||
* Progressive enhancement for the 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.
|
||||
* Two independent behaviours, both optional — without JS every panel stays
|
||||
* visible and the single submit still works:
|
||||
*
|
||||
* 1. **Who are you registering?** The student section is hidden until the
|
||||
* choice is "on behalf of students" or "both", and "Add another student"
|
||||
* clones the student block. "On behalf of students" *alone* also takes the
|
||||
* account holder's own **About you** panel out of play — they are not a
|
||||
* student in that case, so the server ignores their birth year and answers
|
||||
* and the browser must not demand them. Under "both" they are a student and
|
||||
* do fill it in.
|
||||
* 2. **Password strength.** The password is scored with zxcvbn (via WordPress's
|
||||
* own `wp.passwordStrength`) and a weak one is refused. The server applies
|
||||
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
|
||||
*/
|
||||
(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');
|
||||
var PASSWORD = window.usSchedulerPassword || {};
|
||||
|
||||
if (!step1 || !step2 || !next) {
|
||||
/**
|
||||
* Gate the form on password strength.
|
||||
*
|
||||
* The verdict is attached to the field with `setCustomValidity()` rather than
|
||||
* by disabling the submit button: an invalid field blocks the submit without
|
||||
* the button having to know why.
|
||||
*
|
||||
* It is also re-scored on submit, which is the case the input handler alone
|
||||
* misses. zxcvbn's dictionary arrives after page load, and until it does the
|
||||
* meter has no opinion and the field is left valid — so a password typed in
|
||||
* the first second and submitted straight away would otherwise never be
|
||||
* scored at all, and the first the student heard of it would be the server
|
||||
* rejecting the whole form.
|
||||
*/
|
||||
function enhancePassword(form) {
|
||||
var field = form.querySelector('#us-reg-pass');
|
||||
var output = form.querySelector('#us-reg-pass-strength');
|
||||
var strings = PASSWORD.strings || {};
|
||||
|
||||
if (!field || !PASSWORD.minScore) {
|
||||
return;
|
||||
}
|
||||
|
||||
function show(step) {
|
||||
step1.hidden = step !== 1;
|
||||
step2.hidden = step !== 2;
|
||||
}
|
||||
// What the password must not simply repeat back. Mirrors the identity
|
||||
// check PasswordPolicy makes server-side.
|
||||
function identity() {
|
||||
var out = [];
|
||||
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||
|
||||
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;
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var value = (sources[i].value || '').trim();
|
||||
if (value) {
|
||||
out.push(value);
|
||||
if (value.indexOf('@') > 0) {
|
||||
out.push(value.split('@')[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show(2);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
if (back) {
|
||||
back.addEventListener('click', function () {
|
||||
show(1);
|
||||
function assess() {
|
||||
var value = field.value || '';
|
||||
|
||||
if (!value) {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length < (PASSWORD.minLength || 8)) {
|
||||
report(strings.short, 'short');
|
||||
return;
|
||||
}
|
||||
|
||||
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
|
||||
// reports -1 until it arrives. Say nothing and allow the submit in that
|
||||
// window — the server still checks, and the next keystroke re-runs this
|
||||
// once the dictionary is in.
|
||||
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
var score = window.wp.passwordStrength.meter(value, identity(), '');
|
||||
|
||||
if (score < 0) {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (score >= 3) {
|
||||
report(strings.strong, 'strong');
|
||||
} else if (score >= PASSWORD.minScore) {
|
||||
report(strings.medium, 'medium');
|
||||
} else {
|
||||
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the verdict, and make it the field's validity at the same time. */
|
||||
function report(message, level) {
|
||||
var acceptable = '' === level || 'medium' === level || 'strong' === level;
|
||||
|
||||
if (output) {
|
||||
output.textContent = message || '';
|
||||
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
|
||||
}
|
||||
|
||||
field.setCustomValidity(acceptable ? '' : message || '');
|
||||
}
|
||||
|
||||
field.addEventListener('input', assess);
|
||||
field.addEventListener('blur', assess);
|
||||
|
||||
// The identity check depends on these, so a password typed first and an
|
||||
// email typed second is still caught.
|
||||
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
sources[i].addEventListener('change', assess);
|
||||
}
|
||||
|
||||
// Native validation has already run by the time `submit` fires, so a
|
||||
// verdict reached here has to stop the submit by hand.
|
||||
form.addEventListener('submit', function (event) {
|
||||
assess();
|
||||
|
||||
if (!field.checkValidity()) {
|
||||
event.preventDefault();
|
||||
field.reportValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a cloned child block's `children[0][…]` names and ids to the new
|
||||
* index, and clear the values carried over from the block it was cloned from.
|
||||
*/
|
||||
function reindex(block, index) {
|
||||
block.setAttribute('data-child-index', String(index));
|
||||
|
||||
var fields = block.querySelectorAll('input, select, textarea');
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var field = fields[i];
|
||||
|
||||
if (field.name) {
|
||||
field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']');
|
||||
}
|
||||
|
||||
var oldId = field.id;
|
||||
if (oldId) {
|
||||
field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-');
|
||||
|
||||
var label = block.querySelector('label[for="' + oldId + '"]');
|
||||
if (label) {
|
||||
label.setAttribute('for', field.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.checked = false;
|
||||
} else {
|
||||
field.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceGuardian(form) {
|
||||
var choices = form.querySelectorAll('.us-registering-for');
|
||||
var children = form.querySelector('#us-children');
|
||||
var self = form.querySelector('#us-reg-self');
|
||||
|
||||
if (!choices.length || !children) {
|
||||
return;
|
||||
}
|
||||
|
||||
var addButton = children.querySelector('.us-add-child');
|
||||
var nextIndex = 1;
|
||||
|
||||
/** The selected "who are you registering?" value; 'self' if somehow none is. */
|
||||
function mode() {
|
||||
for (var i = 0; i < choices.length; i++) {
|
||||
if (choices[i].checked) return choices[i].value;
|
||||
}
|
||||
return 'self';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the form in step with the choice.
|
||||
*
|
||||
* Two independent questions, which is why "both" needs its own answer to
|
||||
* each:
|
||||
*
|
||||
* - Are student blocks in play? For "students" and "both".
|
||||
* - Is the account holder a student themselves? For "self" and "both" —
|
||||
* only then are they asked for their own birth year and answers. A pure
|
||||
* guardian gives those per student instead.
|
||||
*
|
||||
* Each panel is disabled as well as hidden. Disabling is what actually
|
||||
* settles it: a `required` field inside a hidden container makes the form
|
||||
* unsubmittable with no way to reach the offending control, and a disabled
|
||||
* fieldset is neither validated nor submitted. The server enforces the
|
||||
* same rules either way.
|
||||
*/
|
||||
function sync() {
|
||||
var current = mode();
|
||||
var wantsStudents = current !== 'self';
|
||||
var asksSelf = current !== 'students';
|
||||
|
||||
children.hidden = !wantsStudents;
|
||||
children.disabled = !wantsStudents;
|
||||
|
||||
// Belt and braces alongside the disabled fieldset, so the required
|
||||
// state is right if a browser ever renders the block on its own.
|
||||
var required = children.querySelectorAll('[data-us-child-required]');
|
||||
for (var r = 0; r < required.length; r++) {
|
||||
required[r].required = wantsStudents;
|
||||
}
|
||||
|
||||
if (self) {
|
||||
self.hidden = !asksSelf;
|
||||
self.disabled = !asksSelf;
|
||||
}
|
||||
}
|
||||
|
||||
for (var c = 0; c < choices.length; c++) {
|
||||
choices[c].addEventListener('change', sync);
|
||||
}
|
||||
sync();
|
||||
|
||||
if (addButton) {
|
||||
addButton.addEventListener('click', function () {
|
||||
var blocks = children.querySelectorAll('.us-child');
|
||||
var clone = blocks[blocks.length - 1].cloneNode(true);
|
||||
|
||||
reindex(clone, nextIndex);
|
||||
nextIndex += 1;
|
||||
|
||||
children.insertBefore(clone, addButton.parentNode);
|
||||
|
||||
// The clone carries the data attribute but not necessarily the
|
||||
// current required state, so settle it the same way as the rest.
|
||||
sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
|
||||
var forms = document.querySelectorAll('.us-register-form form');
|
||||
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
enhance(forms[i]);
|
||||
enhanceGuardian(forms[i]);
|
||||
enhancePassword(forms[i]);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -23,6 +23,10 @@ INCLUDE=(
|
||||
"$SLUG.php"
|
||||
"uninstall.php"
|
||||
"composer.json"
|
||||
# Staged so the production install below resolves to the locked versions
|
||||
# rather than whatever is newest that day. Both are deleted again before
|
||||
# the zip is written.
|
||||
"composer.lock"
|
||||
"src"
|
||||
"templates"
|
||||
"assets"
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"stripe/stripe-php": "^17.0"
|
||||
"stripe/stripe-php": "^21.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
@@ -36,6 +36,9 @@
|
||||
"build": "bash bin/build-zip.sh"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
|
||||
Generated
+2589
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
||||
# CI
|
||||
|
||||
CI and release jobs do not install PHP. They run inside the shared images
|
||||
maintained in [Unsupervised/ci-php](https://git.unsupervised.ca/Unsupervised/ci-php):
|
||||
|
||||
```
|
||||
git.unsupervised.ca/unsupervised/ci-php:8.1
|
||||
git.unsupervised.ca/unsupervised/ci-php:8.2
|
||||
git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||
git.unsupervised.ca/unsupervised/ci-php:8.5
|
||||
```
|
||||
|
||||
The `Unsupervised` org is public, so they pull anonymously — no registry
|
||||
credentials in any job here. What the images contain, how they are published,
|
||||
and how to add a PHP version are documented in that repository's README.
|
||||
|
||||
## Which job runs where
|
||||
|
||||
| Job | Runs in |
|
||||
|---|---|
|
||||
| Coding Standards (PHPCS) | `ci-php:8.3` |
|
||||
| Static Analysis (PHPStan) | `ci-php:8.3` |
|
||||
| Tests | `ci-php:${{ matrix.php }}` |
|
||||
| Build Plugin Zip | `ci-php:8.3` |
|
||||
| No Debug Code | runner image — no PHP, and it uses GNU `grep --include` |
|
||||
| Open next-version bump PR (release.yml) | runner image — no PHP |
|
||||
|
||||
PHPCS and PHPStan are separate jobs so a coding-standards failure still lets
|
||||
the static analysis result through. They run in parallel.
|
||||
|
||||
## Composer
|
||||
|
||||
`composer.lock` is committed, so every job installs the same dependency set
|
||||
and two builds of the same tag ship the same vendor tree. `bin/build-zip.sh`
|
||||
stages the lock into its build directory for the same reason, then removes it
|
||||
before writing the zip.
|
||||
|
||||
The Composer download cache lives at `/composer/cache` — `COMPOSER_HOME` is
|
||||
`/composer` in the image — and is keyed on `composer.lock`.
|
||||
|
||||
## Adding a PHP version to the test matrix
|
||||
|
||||
The image has to exist first. Add the version to the `php` matrix in
|
||||
`ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
|
||||
matrix in `.gitea/workflows/ci.yml` here.
|
||||
@@ -42,6 +42,34 @@ never be used to create a policy-less account (`Auth\EmailConfirmationHandler`):
|
||||
- `login_init` action redirects any `action=register` request (GET **and** POST) to the registration page before any processing runs.
|
||||
- `registration_errors` filter is a fail-safe that rejects `register_new_user()` outright.
|
||||
|
||||
### Holding signups that came from somewhere else
|
||||
Blocking core's own form is not the whole story. `users_can_register=1` and
|
||||
`default_role=us_student` are *site-wide* settings, so they also arm every other
|
||||
route into `wp_insert_user()` a site happens to have — another plugin's signup
|
||||
form, a membership add-on. An account minted that way arrives holding
|
||||
`book_lesson`, with no email confirmed, no studio approval and no policy
|
||||
acceptance on file, and could book and be billed immediately.
|
||||
|
||||
So the pending state is not decided by whichever form created the account. It is
|
||||
decided once, on `user_register`, by
|
||||
`Auth\RegistrationLoginGate::holdUnknownSignup()`:
|
||||
|
||||
| New account | Result |
|
||||
|---|---|
|
||||
| Not a `us_student` | untouched — instructors and everyone else are not this feature's business |
|
||||
| Created by someone holding `manage_students` (including an admin adding a user in wp-admin) | left active — a deliberate act by someone who could approve it in the next click |
|
||||
| Anything else | **held** via `RegistrationStatus::hold()` and queued under **Pending Students** |
|
||||
|
||||
A hold sets `us_awaiting_approval=1` *and* `us_email_confirmed=1`. The account was
|
||||
never asked to confirm anything and has no token to answer with, so blocking its
|
||||
login would strand it; what the hold withholds is `book_lesson`, until a studio
|
||||
admin approves it.
|
||||
|
||||
The plugin's own paths all land in the last row and then say what they meant:
|
||||
- a self-signup calls `RegistrationStatus::markPending()`, which replaces the hold with a real, unconfirmed pending state (it clears `us_email_confirmed` explicitly for exactly this reason);
|
||||
- an invited student is approved outright by `RegistrationPage` before the auto-login — the invitation *is* the approval;
|
||||
- a guardian's child is approved outright by `GuardianService::createChild()`; the account is never signed in to, and queueing every child a family adds would be nonsense.
|
||||
|
||||
## Account Lifecycle (self-approval)
|
||||
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
|
||||
confirmation token's SHA-256 hash is stored; the token expires after 48h
|
||||
@@ -77,14 +105,62 @@ 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)
|
||||
## Email and password validation
|
||||
|
||||
Both are checked on the server on every signup path, and the browser is given a
|
||||
matching but *stricter* job so a bad password is caught before submitting.
|
||||
|
||||
**Email** — `type="email"` and `required` in the markup, `is_email()` on the
|
||||
server, then `email_exists()` for "an account already exists for this email". A
|
||||
personal invite fixes the address and the server always uses the invite's own
|
||||
value, so a tampered field is ignored rather than validated.
|
||||
|
||||
**Password** — `Auth\PasswordPolicy` is the authority. It deliberately does
|
||||
*not* try to reproduce a strength score in PHP; it rejects the categorically
|
||||
bad, which is what a server can check without shipping a dictionary:
|
||||
|
||||
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
|
||||
composition rules like "must contain a symbol" are deliberately **not** used,
|
||||
as they push people towards predictable substitutions),
|
||||
- one of the well-known leaked passwords,
|
||||
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
|
||||
- containing the user's own display name, email, or the part before the `@`.
|
||||
|
||||
The nuance happens in the browser. `register.js` scores the password with
|
||||
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
|
||||
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
|
||||
guessable password without demanding a passphrase to book a piano lesson). The
|
||||
thresholds reach JavaScript via `wp_localize_script()` from the same constants
|
||||
the server enforces, so the two cannot drift apart.
|
||||
|
||||
The verdict is applied with `setCustomValidity()` on the password field rather
|
||||
than by disabling a button: an invalid field stops the submit without the button
|
||||
needing to know why. zxcvbn's dictionary loads asynchronously, so the gate stays
|
||||
open until it arrives — the server is the check that always runs.
|
||||
|
||||
The password is also **re-scored on submit**, not only as it is typed. Native
|
||||
validation has already run by the time the `submit` event fires, so a verdict
|
||||
reached there stops the submit by hand (`preventDefault()` + `reportValidity()`).
|
||||
Without that, a password typed in the second before the dictionary arrived was
|
||||
never scored at all, and the first the person heard of it was the server
|
||||
rejecting the whole form.
|
||||
|
||||
## Registration Questions
|
||||
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.
|
||||
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), they
|
||||
are asked on the main form in an **About you** panel — alongside the account
|
||||
holder's birth year, above the students they are adding, and only when they are a
|
||||
student themselves (`self` or `both`). 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.
|
||||
|
||||
The form is one page with one submit. The questions used to be a second step
|
||||
behind a "Next" button; that put what the studio needs to know about an adult
|
||||
student on a screen reached only after everything else, and the two-step gate is
|
||||
what made a weak password reachable — it advanced on a `checkValidity()` that had
|
||||
not yet scored anything.
|
||||
|
||||
## Policy Acceptance Scope
|
||||
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
|
||||
@@ -97,7 +173,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. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
|
||||
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
|
||||
|
||||
## Flow (self-approval mode)
|
||||
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
|
||||
@@ -126,6 +202,14 @@ 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()`).
|
||||
|
||||
## Where Students Go Next
|
||||
The block's **After registration** panel picks the page a student continues to once
|
||||
registration finishes, and whether they get there by hand or automatically.
|
||||
|
||||
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the **"Continue to _<page title>_"** link every **logged-in** visitor gets (`RegistrationPage::continueLink()`): an invited student who just finished signing up (`?us_registered=invite`), and anyone who simply arrives at the registration page already signed in. The link names the chosen page (via `get_the_title()`) so the visitor knows where it goes; an untitled page falls back to "Continue to your account" rather than reading "Continue to ". Neither gets the WordPress-login-screen fallback — with no page chosen there is no link at all, since sending someone already signed in to the login screen is the same dead end with extra steps.
|
||||
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
|
||||
|
||||
## Token Redirect
|
||||
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
||||
@@ -142,7 +226,7 @@ No-op when no registration page is set.
|
||||
- Repository: `Unsupervised\Schedular\Auth\InviteRepository`
|
||||
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
|
||||
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
|
||||
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta), `Auth\RegistrationLoginGate` (login + booking-cap gate), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
|
||||
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta, including `hold()`), `Auth\RegistrationLoginGate` (login gate, booking-cap gate, and the `user_register` hold), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
|
||||
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
|
||||
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
|
||||
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
|
||||
@@ -157,3 +241,12 @@ No-op when no registration page is set.
|
||||
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
|
||||
- `tests/Unit/Auth/RegistrationMailerTest.php`
|
||||
- `tests/Unit/Payment/StudioSettingsTest.php`
|
||||
|
||||
## Parent/Guardian Signup
|
||||
The registration form asks **"Who are you registering?"** — just myself, on behalf
|
||||
of one or more students, or both — and the student-bearing choices reveal a
|
||||
repeatable child block (name, birth year, and the account-scope questions asked
|
||||
**per child**). Each child becomes a login-less `us_student` user linked to the
|
||||
guardian, and the signup policies are recorded once per child with the guardian as
|
||||
the acceptor. Available on every signup path — personal invite, group link, and
|
||||
self-approval. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -25,8 +25,9 @@ A slot's `duration_minutes` is matched against the offering a student picks: a
|
||||
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
|
||||
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
|
||||
one row per chunk. A trailing remainder shorter than the lesson length is
|
||||
dropped. Windows must start and end on the same day and fit at least one lesson
|
||||
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
|
||||
dropped. Windows must start and end on the same day and fit at least one lesson;
|
||||
both the REST endpoint and the admin form reject one that does not, with a
|
||||
message saying so (see **REST API** below).
|
||||
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
|
||||
`Installer` on activation or version change) that rewrites pre-split rows.
|
||||
|
||||
@@ -44,11 +45,34 @@ Instructors access **My Availability** in wp-admin (`?page=us-availability`).
|
||||
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
|
||||
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
|
||||
|
||||
### Feedback
|
||||
Every submitted action reports its outcome as a wp-admin notice — a success
|
||||
notice naming the number of slots created or deleted, or an error explaining the
|
||||
refusal. `AvailabilityController::handleFormAction()` returns a
|
||||
`[$notice, $error]` pair that `templates/admin/availability.php` renders.
|
||||
|
||||
This matters because the form used to fail **silently**: a window shorter than
|
||||
the chosen lesson length splits into no slots, so nothing was written, nothing
|
||||
was said, and the page simply reloaded. Submitting 5:30–6:00 PM with the length
|
||||
select on its 60-minute default was the reported case. Invalid datetimes, an end
|
||||
before the start, a window spanning two days, and an offering belonging to
|
||||
another instructor were all silent in the same way.
|
||||
|
||||
### Lesson-length choices
|
||||
`assets/js/availability-admin.js` (enqueued by `AdminMenu::enqueueAssets()` on
|
||||
this screen only) hides any lesson length longer than the entered window, falls
|
||||
back to the longest one that still fits when the current pick is hidden, and
|
||||
disables the submit button when nothing fits. It is a convenience, not a
|
||||
guarantee — the server validates the same window regardless. The choices come
|
||||
from `AvailabilitySlot::DURATION_CHOICES`.
|
||||
|
||||
## Public Calendar
|
||||
The front-end booking shortcode renders open slots from `GET /availability`
|
||||
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
|
||||
site's `start_of_week` option is passed through the `usScheduler` JS config).
|
||||
Both views can be narrowed to the slots bookable as chosen private-lesson types
|
||||
with the **Show Only** lesson-type filter — see `lesson-booking.md`.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
@@ -60,12 +84,29 @@ site's `start_of_week` option is passed through the `usScheduler` JS config).
|
||||
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
||||
Slots whose start has already passed are never returned.
|
||||
|
||||
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
|
||||
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
|
||||
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
|
||||
anything else — or an end not after the start — is rejected (REST responds
|
||||
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
|
||||
`POST` runs every submitted window — admin form and REST alike — through
|
||||
`Availability\WindowValidator`, which returns either the window ready to persist
|
||||
or a `WP_Error`. The REST endpoint returns that error directly (its `status`
|
||||
data makes it a 400); the admin screen shows `get_error_message()` in a notice.
|
||||
Sharing one validator is deliberate: the two paths previously checked the same
|
||||
rules separately, and the admin copy was both laxer (no offering-ownership
|
||||
check) and mute (a bare `return` on every rejection).
|
||||
|
||||
| Rejection | Code |
|
||||
|---|---|
|
||||
| Start or end not a real datetime | `invalid_datetime` |
|
||||
| End at or before the start | `invalid_datetime` |
|
||||
| Window spans two days | `invalid_window` |
|
||||
| Window shorter than the lesson length (so it holds no slots) | `invalid_window` |
|
||||
| Offering missing, or owned by another instructor | `invalid_offering` |
|
||||
|
||||
`start_dt`/`end_dt` are normalised by `AvailabilitySlot::normalizeDateTime()`:
|
||||
the canonical `Y-m-d H:i[:s]` and HTML `datetime-local` (`Y-m-d\TH:i[:s]`) forms
|
||||
become `Y-m-d H:i:s`; anything else is rejected. A valid window is stored as
|
||||
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
|
||||
`weeks` is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES` in the
|
||||
repository, so the form's `max` cannot be bypassed by posting directly. A write
|
||||
that fails entirely returns `500 not_saved` rather than a `201` listing no ids.
|
||||
|
||||
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
|
||||
lists.
|
||||
@@ -76,6 +117,8 @@ lists.
|
||||
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
|
||||
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
||||
- Shared window validation: `Unsupervised\Schedular\Availability\WindowValidator`
|
||||
- Admin form script: `assets/js/availability-admin.js`, enqueued by `AdminMenu::enqueueAssets()`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Availability/AvailabilityControllerTest.php`
|
||||
@@ -83,3 +126,4 @@ lists.
|
||||
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
||||
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
|
||||
- `tests/Unit/Availability/WeekCalendarTest.php`
|
||||
- `tests/Unit/Availability/WindowValidatorTest.php`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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`)
|
||||
|
||||
## Family Balances
|
||||
A credit records the student it was earned for (`student_id`) and the account
|
||||
that **holds** it (`payer_id`). Balance lookups — `availableBalance()`,
|
||||
`findAvailableByPayer()`, `consume()` — key on the payer, so a family shares one
|
||||
balance and a credit from one child's cancelled lesson can settle a sibling's
|
||||
next charge. A child's admin screen still lists the credits their own
|
||||
cancellations produced, labelled with whose account holds the balance. See
|
||||
`parent-guardian-accounts.md`.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Feature: Data Removal
|
||||
|
||||
## Overview
|
||||
What deleting the plugin takes with it, and how the site owner says so.
|
||||
|
||||
WordPress gives an uninstall no interface of its own: `uninstall.php` runs
|
||||
headless, after the plugin is already gone from the screen, with no opportunity
|
||||
to ask anything. So the answer is given in advance, on **Access → Plugin
|
||||
removal**, and read back at uninstall time.
|
||||
|
||||
Two things are true at once, and the split below is how both are honoured:
|
||||
|
||||
- **A studio's records are irreplaceable.** Lessons taught, payments taken, what
|
||||
families agreed to and when. A delete during a migration, or a
|
||||
delete-and-reinstall while troubleshooting, must not be the thing that loses
|
||||
them. So the data is **kept unless the owner explicitly says otherwise**.
|
||||
- **Credentials are not records.** A Stripe secret and webhook signing key can be
|
||||
re-pasted from the Stripe dashboard in under a minute. Live keys sitting in
|
||||
`wp_options` on a site that no longer has the code to use them are nothing but
|
||||
exposure. So those are **always** removed.
|
||||
|
||||
## Option `us_delete_data_on_uninstall`
|
||||
`'1'` or `'0'` (default `'0'`). Written only from the Access page.
|
||||
|
||||
## Always removed
|
||||
Whatever the setting says:
|
||||
|
||||
| What | Why |
|
||||
|---|---|
|
||||
| `us_stripe_secret_key`, `us_stripe_webhook_secret`, `us_stripe_publishable_key`, `us_stripe_mode` | Credentials, not records — cheap to restore, dangerous to leave |
|
||||
| `us_schedular_latest_release` transient | Cached release metadata; meaningless without the plugin |
|
||||
| The `us_generate_due_payments` cron event | Deactivation clears it too, but a site whose plugin files simply vanished never ran that hook |
|
||||
| `users_can_register` / `default_role` | Restored from the snapshot open registration took (`us_registration_prev_*`). These are the *site's* settings, borrowed; leaving them behind would leave the site accepting public signups into a Student role that is about to stop existing. Only acts when a snapshot exists |
|
||||
|
||||
## Removed only on an explicit full purge
|
||||
- Every table in `Schema::TABLES` — all 14, dropped by name.
|
||||
- Every remaining `us_*` option, including the removal setting itself.
|
||||
- Every `us_*` user meta key, for all users (`delete_metadata( 'user', 0, $key, '', true )`) — billing overrides, child markers, birth years, pending-signup state.
|
||||
- The `us_studio_admin`, `us_instructor` and `us_student` roles.
|
||||
|
||||
Roles go **only** on a full purge. A site keeping its data is keeping its
|
||||
students, and a student whose role was deleted out from under them holds no
|
||||
capabilities at all until the plugin is reinstalled.
|
||||
|
||||
`Schema::TABLES` is the single list of tables the plugin owns; the `CREATE TABLE`
|
||||
statements in `Schema::tables()` spell their own names out, so a new table has to
|
||||
be added in both places or uninstalling will leave it behind.
|
||||
|
||||
## Admin Interface
|
||||
**Access → Plugin removal** (`manage_options` — the same capability as deleting a
|
||||
plugin, so the switch and the act it governs are in the same pair of hands):
|
||||
|
||||
- A read-only note stating what is removed regardless.
|
||||
- **Erase everything when the plugin is deleted** — off by default.
|
||||
|
||||
Turning it **on** takes the tick *and* the word `DELETE` typed into a confirmation
|
||||
box. It is the only place in the plugin where a stray click is unrecoverable, so
|
||||
the checkbox alone is not enough. A refused confirmation reports why and still
|
||||
saves everything else on the page — a mistyped word must not silently swallow a
|
||||
capability change made in the same submit.
|
||||
|
||||
Turning it **off** needs nothing but unticking the box. Saving the page for some
|
||||
other reason while it is already on leaves it on, without asking for the word
|
||||
again.
|
||||
|
||||
## Implementation
|
||||
- `Unsupervised\Schedular\Uninstaller` — the option, and the whole of `run()`
|
||||
- `uninstall.php` — guards on `WP_UNINSTALL_PLUGIN`, loads the autoloader, calls `Uninstaller::run()`
|
||||
- `Unsupervised\Schedular\Schema::TABLES` — the table list
|
||||
- `Unsupervised\Schedular\Auth\AccessSettings` — the setting's UI and the typed confirmation
|
||||
- `templates/admin/access.php`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/UninstallerTest.php`
|
||||
- `tests/Unit/Auth/AccessSettingsTest.php` (the confirmation rules)
|
||||
|
||||
## Related
|
||||
- `payments.md` — the Stripe credentials this always forgets
|
||||
- `account-registration.md` — the core options open registration borrows
|
||||
@@ -1,8 +1,8 @@
|
||||
# Editor Blocks
|
||||
|
||||
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
|
||||
so the pages can be previewed and styled inside the block editor instead of
|
||||
appearing as grey shortcode text.
|
||||
Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
|
||||
pages can be previewed and styled inside the block editor instead of appearing
|
||||
as grey shortcode text.
|
||||
|
||||
## Blocks
|
||||
|
||||
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
|
||||
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
|
||||
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
|
||||
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
|
||||
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
|
||||
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
|
||||
|
||||
The shortcodes remain registered for back-compat; blocks and shortcodes share
|
||||
the same page objects (constructed once in `Plugin::boot()`), so front-end
|
||||
@@ -21,23 +23,37 @@ transform.
|
||||
|
||||
## Block options
|
||||
|
||||
Four blocks have sidebar (inspector) options:
|
||||
Most blocks have sidebar (inspector) options:
|
||||
|
||||
| Block | Attribute | Default | Effect |
|
||||
|---|---|---|---|
|
||||
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
|
||||
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
|
||||
| `us-scheduler/booking` | `lessonTypeId` (number) | `0` | Pin the calendar to a single private-lesson type: only the times bookable as that type are listed, and it is the only type students can book here (auto-selected on the registration form). `0` = every type. Shortcode equivalent: `[us_booking lesson_type="…"]`. |
|
||||
| `us-scheduler/booking` | `showTypeFilter` (boolean) | `true` | Whether students get the **Show Only** button that narrows the calendar to chosen lesson types. Unused when a single type is pinned (there is nothing to choose). Shortcode equivalent: `[us_booking show_filter="no"]`. |
|
||||
| `us-scheduler/booking` | `displayMode` (string) | `both` | Which halves of the page to embed: `both`, `booking` (calendar only, no upcoming-lessons panel) or `upcoming` (the student's lessons only, nothing bookable) — so the two halves can live on different pages. Anything unrecognised falls back to `both`. Shortcode equivalent: `[us_booking show="booking"]`. |
|
||||
| `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/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
||||
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
|
||||
| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
|
||||
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account 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. 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
|
||||
select is a dropdown of active group classes fetched from
|
||||
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
|
||||
longer offered shows as "Unavailable class #N" rather than silently falling
|
||||
back to all classes. The link targets are also available
|
||||
back to all classes. The booking block's lesson-type select works the same way
|
||||
against `?kind=private_lesson` ("Unavailable lesson type #N"), and the live
|
||||
page says so plainly when the pinned type has been withdrawn.
|
||||
|
||||
The booking block's options reach the front end as data attributes on
|
||||
`#us-booking-app` (`data-lesson-type`, `data-type-filter`) or as omitted
|
||||
containers (`displayMode`), which `assets/js/booking.js` reads on load — see
|
||||
`lesson-booking.md`. Its editor preview follows `displayMode`, showing the
|
||||
calendar, the upcoming-lessons panel, or both. The link targets are also available
|
||||
to the shortcodes as `[us_booking login_page_id="…"]` and
|
||||
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
||||
|
||||
@@ -48,6 +64,15 @@ queried singular post's content for the block (including inside nested
|
||||
blocks), and redirects when the block opts in. A block whose target is its
|
||||
own page is ignored to avoid a redirect loop.
|
||||
|
||||
The registration block's auto-redirect additionally only fires on a
|
||||
**finished** registration — `RegistrationPage::isRegistrationComplete()`: an
|
||||
invited student who is now logged in (`?us_registered=invite`), or a
|
||||
self-signup back from the emailed confirmation link (`?us_confirmed=ready|1`).
|
||||
The intermediate "check your email" step and every failure (a validation
|
||||
error, `?us_confirmed=expired`) stay on the page so the student reads the
|
||||
message. That check runs before the content is parsed, so an ordinary page
|
||||
view does not pay for the extra block scan.
|
||||
|
||||
## How it works
|
||||
|
||||
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
|
||||
@@ -78,10 +103,16 @@ 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.
|
||||
- **Account** — a populated sample panel. Deliberately populated whatever the
|
||||
editor user's own state: on the published page a signed-out visitor may see
|
||||
nothing at all, and an empty box tells the person placing the block nothing
|
||||
about where it will sit.
|
||||
|
||||
Each preview starts with a `.us-editor-note` paragraph explaining what the
|
||||
published page shows instead. The note class only appears in editor previews.
|
||||
@@ -95,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
|
||||
and fallbacks.
|
||||
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
|
||||
fallbacks.
|
||||
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
|
||||
sign-out redirect target, and the signed-out empty render.
|
||||
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
|
||||
classes/ids and includes the editor note.
|
||||
|
||||
## The account block's signed-out behaviour
|
||||
|
||||
`us-scheduler/account` is the one block that can render **nothing**. It is meant
|
||||
for a header, sidebar or account page, and its whole subject is the person
|
||||
signed in — which a stranger is not. A bare "you are not signed in" in a site
|
||||
header is noise that cannot be acted on, so:
|
||||
|
||||
- **No login page chosen** → empty string for signed-out visitors.
|
||||
- **Login page chosen** → a single **Sign in** link.
|
||||
|
||||
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
|
||||
is never exposed), the account email, and a **Sign out** link — deliberately
|
||||
nothing else. Signing out returns to the chosen login page, or to the current page when there
|
||||
is none, so a header sign-out does not also navigate the visitor somewhere.
|
||||
|
||||
+184
-25
@@ -15,6 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
|
||||
| `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised from the offering) |
|
||||
| `status` | VARCHAR(20) | `active` / `cancelled` / `completed` |
|
||||
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||
| `enrolled_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves |
|
||||
| `enrolled_at` | DATETIME | Insertion time |
|
||||
|
||||
## Class Dates, Time, and Instructor
|
||||
@@ -31,28 +32,127 @@ 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
|
||||
(`GET /enrollments`); a class the student already has an `active` enrolment in
|
||||
shows "You are enrolled in this class." instead of the Enrol button (the
|
||||
server would reject the duplicate with `409 already_enrolled` regardless — a
|
||||
cancelled enrolment does not block re-enrolling).
|
||||
### Sessions in the "upcoming" views
|
||||
`GroupClass\SessionSchedule` turns an enrolment into the dated sessions behind it,
|
||||
so a class appears alongside one-to-one lessons wherever upcoming lessons are
|
||||
listed. A class is a term, not rows in `us_availability`, so an enrolment carries
|
||||
no date of its own — the concrete windows come from `Offering::sessionWindows()`,
|
||||
the same derivation the billing scan and the class-slot reconciler use, which is
|
||||
what keeps a student's list, an instructor's list and the invoice agreeing on when
|
||||
the class meets.
|
||||
|
||||
1. Student opens a group class from the offering catalog.
|
||||
- `upcomingForStudent()` — every not-yet-started session of each enrolment that is
|
||||
not `cancelled`. `completed` is a *billing* state and says nothing about the
|
||||
calendar, so those sessions stay listed.
|
||||
- `upcomingForInstructor()` — every session of each active group class they own,
|
||||
one row per session however many students are enrolled; enrolments are not
|
||||
consulted, because a class still has to be taught if nobody has signed up yet.
|
||||
|
||||
**A class you are enrolled in must never silently vanish from these lists.** Both
|
||||
the class time and the duration are optional on the offering form, and the
|
||||
schedule note exists precisely so a studio can write "Tuesdays 4:00pm" rather than
|
||||
pin the class to a clock. So the schedule degrades instead of disappearing:
|
||||
|
||||
| Class has | What the list gets |
|
||||
|---|---|
|
||||
| date + time + duration | one dated row per remaining session, with an end time |
|
||||
| date + time, no duration | one dated row per remaining session, `end_dt` empty — when it starts is worth showing without guessing when it ends |
|
||||
| no class time | **one** row for the class as a whole, sorted by term start (or by "now" once the term is under way), with `schedule` text from `Offering::scheduleLabel()` — the studio's note, else the term dates, else "Schedule to be confirmed" |
|
||||
| a term whose last day has passed | nothing |
|
||||
|
||||
`schedule` is the tell: non-null means "a class, described in words, not a session
|
||||
at a known time", and every renderer shows that text in place of a date and time.
|
||||
An undated row's `start_dt` is a **sort key only** — never displayed.
|
||||
|
||||
`Offering::sessionStarts()` is the split that makes this work: it needs only the
|
||||
date and the time, because knowing *when* a class meets is a separate question
|
||||
from knowing how long it runs. `sessionWindows()` is that plus the duration, and
|
||||
still returns nothing without one — availability blocking and per-session billing
|
||||
need both ends of a window.
|
||||
|
||||
Consumers mark these rows `kind = 'group_class'` (`SessionSchedule::KIND`) and
|
||||
withhold the per-lesson actions from them: a session is one date in a term, not a
|
||||
booked slot, so there is nothing to cancel session by session and no slot to
|
||||
release. Withdrawing from the class is the separate, whole-enrolment decision.
|
||||
|
||||
Where they show up: the `[us_scheduler]` upcoming panel via `GET /bookings`
|
||||
(students and instructors both), and the **Upcoming lessons** table on the admin
|
||||
student detail page. Only *upcoming* sessions are added there — the
|
||||
**Group-class enrolments** table below already records the whole history, and a
|
||||
term's worth of past dates would bury the lessons under "Past lessons".
|
||||
|
||||
## Enrolment Flow
|
||||
The class list is loaded together with the household's enrolments
|
||||
(`GET /enrollments`), and the two are matched up **per student**, not per account.
|
||||
Each active enrolment in a class adds its own line to the card — "Ada is enrolled
|
||||
in this class." — with its own **Withdraw** button, and the Enrol button stays
|
||||
(reading "Enrol another student") for as long as anyone the account may enrol is
|
||||
still out of the class. The enrolment form then offers only those students; when
|
||||
exactly one is left the picker collapses to a hidden field carrying that student's
|
||||
id, because an omitted `student_id` reads as "enrol the account holder" and would
|
||||
sign up the parent instead of the last child. Only when the whole household is
|
||||
enrolled does the Enrol button disappear.
|
||||
|
||||
The per-student matching mirrors the server, which rejects a duplicate with
|
||||
`409 already_enrolled` for that `(offering, student)` pair alone — a sibling is
|
||||
never a duplicate, and a cancelled enrolment does not block re-enrolling.
|
||||
|
||||
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
||||
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
4. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
5. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
6. On successful payment (or comp) a receipt is emailed.
|
||||
4. The enrolment form restates the price (with HST) and requires a second, separate agreement to pay that amount before it will submit. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
5. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
6. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
7. On successful payment (or comp) a receipt is emailed.
|
||||
|
||||
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. A guardian sees one
|
||||
per enrolled child, labelled with the child's name, so the right seat is the one released.
|
||||
`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. An enrolment that
|
||||
does not exist and one that is not the caller's own both return the same
|
||||
`404 not_found`, deliberately: two different answers would let any signed-in student
|
||||
walk the id space and count the studio's enrolments, and there is nothing they could
|
||||
do with either answer. A withdrawal of an already-cancelled enrolment is idempotent.
|
||||
`POST /bookings/{id}/cancel` makes the same trade for the same reason.
|
||||
|
||||
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` |
|
||||
| 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
|
||||
@@ -74,13 +174,17 @@ flips their grant from `invited` to `enrolled`.
|
||||
|
||||
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
|
||||
person, separate from the enrolment itself. The instructor manages access from
|
||||
**My Lessons → My Group Classes**, which renders three controls under each invite-only
|
||||
class:
|
||||
**My Lessons → My Group Classes**. **Add students directly** is available on every
|
||||
class's details page (see **Admin Interface**); invite-only classes add two more
|
||||
controls beneath it:
|
||||
|
||||
1. **Add students directly** — the selected registered students are enrolled immediately
|
||||
(`status = active`) with a **pending payment** at the class price (comp students are
|
||||
settled at once by `PaymentService`). No access grant is needed — this writes straight
|
||||
to `us_group_enrollments` + `us_payments`.
|
||||
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
|
||||
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
||||
The enrolment records who added them (`enrolled_by`), which is what later allows
|
||||
its intake to be recorded — see **Recording Intake Collected Elsewhere**.
|
||||
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.
|
||||
@@ -90,6 +194,17 @@ class:
|
||||
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.
|
||||
|
||||
Both student-picking controls vet every posted id with `Auth\RoleManager::isStudent()`
|
||||
before acting on it — the same predicate the picker is built from, and the same one
|
||||
`Booking\AdminBooking` guards a staff booking with. A posted id naming an instructor,
|
||||
an administrator, or an account deleted since the page was drawn is skipped rather
|
||||
than enrolled, so nothing can put a non-student on a roster or raise a payment
|
||||
against one. Being a student is a matter of the **role**, not the `book_lesson`
|
||||
capability, so a guardian's child and a signup still awaiting approval are both
|
||||
fully enrollable — neither may enrol *themselves*, which is exactly what the studio
|
||||
adding them is for. The reported count is what was actually added, so a skipped id
|
||||
shows up as a smaller number.
|
||||
|
||||
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.
|
||||
@@ -121,21 +236,58 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
against capacity, plus a **View details** link (**View & invite** for invite-only
|
||||
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
|
||||
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
|
||||
price, schedule note, description, status), the roster of enrolled students with enrolment
|
||||
and payment status, and — for invite-only classes — an **Invite & enrol students** section
|
||||
listing who has been invited but not yet enrolled alongside the add/make-available/
|
||||
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
|
||||
instructor). Managing who is in an invite-only class is therefore done entirely from this
|
||||
page. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
price, schedule note, enrolment deadline, status), the roster of enrolled students with
|
||||
enrolment and payment status, and an **Add students** section. Every class — public or
|
||||
invite-only — carries the **Add students directly** control there, which enrols the
|
||||
selected students immediately (a late enrolment past the deadline; the section says so
|
||||
when the deadline has passed). Invite-only classes additionally get the
|
||||
**make-available** and **invite-by-email** controls plus the list of who has been invited
|
||||
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
|
||||
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
(`templates/admin/my-group-class-detail.php`) are separate templates.
|
||||
|
||||
## Recording Intake Collected Elsewhere
|
||||
A student the studio added with **Add students directly** has no intake answers
|
||||
and no policy acceptances: they were never shown the enrolment form. The answers
|
||||
are collected another way — a paper form at the first class, a phone call to a
|
||||
parent — and recorded afterwards from the **enrolment detail page**, reached from
|
||||
the **Intake → View** link on each roster row.
|
||||
|
||||
The page shows who and what the enrolment is, the audit trail of everything
|
||||
answered and agreed to so far, and — for a studio-made enrolment only — a
|
||||
**Record intake collected elsewhere** panel offering whatever is still missing.
|
||||
Every recording must say **how** it was collected (signed paper form / in person /
|
||||
over the phone / by email / some other way, the last requiring an explanation),
|
||||
and that is stamped on every row along with who entered it. Both audit tables
|
||||
carry a **How it was given** column, so a policy ticked online and one transcribed
|
||||
from paper never look alike.
|
||||
|
||||
**Only a studio-made enrolment qualifies** (`Enrollment::isStaffRegistered()`,
|
||||
i.e. `enrolled_by > 0`). An enrolment the student made already holds their own
|
||||
answers, and letting staff add to it would make the record editable after the
|
||||
event. Nothing already recorded can be overwritten: the submission is narrowed to
|
||||
what is genuinely still pending before anything is written, so a stale or
|
||||
double-posted form is harmless.
|
||||
|
||||
This is the same mechanism the Scheduler uses for lessons it booked, and the
|
||||
reasoning behind each rule — why no IP is stored, why `accepted_by` stays the
|
||||
student while `recorded_by` names the staff member — is set out once in
|
||||
**Recording Intake Collected Elsewhere** in `lesson-booking.md`. An enrolment is
|
||||
its own registration, so unlike a weekly lesson series there is no anchor to
|
||||
follow: one enrolment, one intake record, however many sessions the term holds.
|
||||
|
||||
Scoping matches the rest of the detail pages: an instructor may only open
|
||||
enrolments in their own classes, a `view_all_lessons` studio admin any.
|
||||
|
||||
## 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` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
|
||||
- Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController`
|
||||
- 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`). Both also route `?enrollment_id=` to the enrolment detail view (`maybeRenderEnrollmentDetail`, template `templates/admin/enrollment-detail.php`)
|
||||
- Intake audit + late recording: `Unsupervised\Schedular\Registration\IntakeAudit` and `IntakeRecording`, shared with lesson bookings. `Enrollment` implements `Registration\IntakeSubject` to take part
|
||||
- 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`
|
||||
@@ -151,4 +303,11 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
- `tests/Unit/GroupClass/GroupAccessTest.php`
|
||||
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
|
||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||
- `tests/Unit/GroupClass/SessionScheduleTest.php`
|
||||
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
|
||||
|
||||
## Enrolling A Child
|
||||
`POST /enrollments` accepts the same optional **`student_id`** as booking,
|
||||
authorised through `Guardian\GuardianService::canActFor()`; `GET /enrollments`
|
||||
covers the guardian's whole household, and a guardian may withdraw any of their
|
||||
children. See `parent-guardian-accounts.md`.
|
||||
|
||||
+200
-11
@@ -17,19 +17,168 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
| `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` |
|
||||
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||
| `notes` | TEXT | Optional student notes |
|
||||
| `booked_by` | BIGINT UNSIGNED | Staff member who booked it for the student; 0 when the student (or their guardian) booked it themselves |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Registration Flow
|
||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
||||
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **Show Only** button beside the view toggle opens a lesson-type filter that narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
|
||||
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
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.
|
||||
6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
8. `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.
|
||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
|
||||
12. 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.
|
||||
|
||||
## Lesson-Type Filter
|
||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||
an offering takes that offering only, and a generic slot only takes types whose
|
||||
length fits. The booking calendar therefore carries a lesson-type filter,
|
||||
collapsed behind a **Show Only** button that sits in the calendar's control row
|
||||
beside the List/Week toggle. Opening it reveals the type list between that row
|
||||
and the calendar: a checkbox per active private-lesson type (from
|
||||
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
|
||||
instructor's name alongside the title when the catalog spans more than one
|
||||
instructor. The button carries the number of ticked types and stays highlighted
|
||||
while the filter is on, so a collapsed filter is never invisible. Both button and
|
||||
list are hidden when there is only one bookable type.
|
||||
|
||||
Ticking one or more types narrows the calendar to the slots bookable as one of
|
||||
them; no ticks means no filter, and collapsing the list leaves the filter
|
||||
applied. Picking a filtered slot narrows the registration form's **Lesson type**
|
||||
picker the same way, and when exactly one type remains it is pre-selected and its
|
||||
intake questions load immediately. Changing the filter re-anchors the week view
|
||||
on the earliest matching slot, so the student never lands on an empty week.
|
||||
**Show all types** clears the filter.
|
||||
|
||||
Bookability is decided client-side by `offeringFitsSlot()` in
|
||||
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
|
||||
instructor, the tied offering when there is one, otherwise a matching
|
||||
`duration_minutes`). The filter is a browsing aid only: the server re-checks
|
||||
every booking regardless.
|
||||
|
||||
Two block/shortcode options change what the filter has to work with (see
|
||||
`editor-blocks.md`), passed to the script as data attributes on
|
||||
`#us-booking-app`:
|
||||
|
||||
- **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one
|
||||
offering, so the page lists only the times bookable as it and books nothing
|
||||
else — the filter control hides itself, there being one type left. A pinned
|
||||
type that is no longer offered shows "This lesson type is not available for
|
||||
booking right now" rather than an empty calendar.
|
||||
- **Filter off** (`data-type-filter="0"`) drops the **Show Only** button
|
||||
entirely; every open time is listed, as before the filter existed.
|
||||
|
||||
## Embedding Halves of the Page
|
||||
The page has two halves — the booking calendar and the student's upcoming
|
||||
lessons — and the block/shortcode can embed either on its own (`displayMode` /
|
||||
`show`: `both` (default), `booking`, `upcoming`). The template simply omits the
|
||||
containers of the half that is not wanted, and the script skips the work that
|
||||
belongs to a missing container: an upcoming-only embed never requests
|
||||
availability or the offering catalog, and a booking-only embed never requests
|
||||
`GET /bookings`. An unrecognised value renders the whole page, so a typo cannot
|
||||
silently hide half of it.
|
||||
|
||||
## Booking For A Student (Admin)
|
||||
A guardian can book for their children, but nobody else can book for anyone —
|
||||
which leaves the studio unable to take a booking over the phone, and an
|
||||
instructor unable to slot in a make-up lesson. **Book a lesson for a student**,
|
||||
a collapsed panel at the top of both **Scheduler** and **My Lessons**, is the
|
||||
private-lesson counterpart to the group class's **Add students directly**.
|
||||
|
||||
Pick the student, an open time, and (for a general time) the lesson type; tick
|
||||
**Reserve this time weekly** for a term, **No charge** for a make-up or goodwill
|
||||
lesson. The times offered are the open slots of the next eight weeks — every
|
||||
instructor's on the studio **Scheduler**, only the instructor's own on **My
|
||||
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
|
||||
posted slot id. The result is reported as a notice above the panel saying what
|
||||
was booked and what it left owing; a refusal reopens the panel with the reason
|
||||
and every field as it was submitted, so only the mistake needs correcting. A
|
||||
booking that succeeds clears the form, so the next one does not inherit it.
|
||||
|
||||
It is the same booking a student makes — `LessonBooker` claims the slot(s),
|
||||
writes the lesson row(s), and raises the payment exactly as `POST /bookings`
|
||||
does — and differs in four deliberate ways:
|
||||
|
||||
1. **No intake answers or policy acceptances are recorded at booking time.**
|
||||
Those are the student's to give; staff ticking the boxes for them would be an
|
||||
audit trail that says something untrue. They can instead be collected some
|
||||
other way and recorded afterwards — see **Recording Intake Collected
|
||||
Elsewhere**.
|
||||
2. **It is not bounded by what the student could book themselves**, the way a
|
||||
direct group-class enrolment bypasses the enrolment deadline.
|
||||
3. **It can be booked at no charge** — no payment at all, and the lesson (or
|
||||
whole series) is `confirmed` at once. Without the tick a pending payment is
|
||||
raised at the lesson type's price, per-occurrence for a weekly reservation,
|
||||
and the lesson confirms when it settles like any other.
|
||||
4. **It can book for a student who cannot book at all.** The guard is
|
||||
`Auth\RoleManager::isStudent()` — the student *role*, not the `book_lesson`
|
||||
capability — so it covers a guardian's child and a self-signup still awaiting
|
||||
approval alike, and is shared with the group-class **Add students directly**
|
||||
and **Make available** controls so the two paths cannot drift. Both hold the role;
|
||||
both have `book_lesson` withheld (`Guardian\ChildLoginGate`,
|
||||
`Auth\RegistrationLoginGate`) so that neither can book in their own name.
|
||||
That restriction is on them, not on the studio acting for them — and for a
|
||||
child, whose account is never signed in to, it is the only route to a lesson
|
||||
besides their guardian's. The picker and the guard therefore accept exactly
|
||||
the same set, so nothing offered in the panel can be refused as ineligible.
|
||||
|
||||
A weekly reservation needs a time that actually repeats: asked for one on a
|
||||
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
|
||||
single lesson, since the person booking asked for a term and would otherwise
|
||||
find out from the roster.
|
||||
|
||||
## Recording Intake Collected Elsewhere
|
||||
A lesson the studio booked has no intake answers and no policy acceptances,
|
||||
because nobody was at a keyboard to give them. The studio collects them another
|
||||
way — a paper form at the first lesson, a phone call — and records them
|
||||
afterwards from the lesson's **detail page**: a **Record intake collected
|
||||
elsewhere** panel below the two audit tables.
|
||||
|
||||
**Only a staff-booked lesson has the panel** (`Lesson::isStaffRegistered()`, i.e.
|
||||
`booked_by > 0`). A lesson the student booked already carries their own answers,
|
||||
and letting staff add to them would make the record editable after the fact. The
|
||||
same instructor/studio scoping as the rest of the detail page applies: an
|
||||
instructor may only open their own lessons, the studio **Scheduler** any.
|
||||
|
||||
The panel offers **only what is still missing** — questions with no answer,
|
||||
current policy versions with no acceptance — and narrows the submission to that
|
||||
set again before writing, so a stale or double-posted form can neither duplicate
|
||||
a row nor overwrite one. Nothing is compulsory except the provenance: a studio
|
||||
holding half the answers records the half it has and comes back for the rest.
|
||||
|
||||
### How they were collected
|
||||
Every recording must say **how** the answers reached the studio — on a signed
|
||||
paper form, in person, over the phone, by email, or some other way (which must be
|
||||
explained in the accompanying note). The method and note are stamped on every row
|
||||
the recording writes, alongside **who typed it in**, and both audit tables carry a
|
||||
**How it was given** column reading either "Given online when booking" or, say,
|
||||
"On a signed paper form — Filed in the studio binder — recorded by Jane Doe".
|
||||
|
||||
That column is the point of the feature. "Accepted on 24 Aug" means one thing
|
||||
when a student ticked a box and quite another when a staff member transcribed it,
|
||||
and an audit trail that cannot tell them apart is worse than none, because it
|
||||
looks like one.
|
||||
|
||||
Two details keep the record honest:
|
||||
|
||||
- **No IP address is stored.** The student was never at a browser; borrowing the
|
||||
staff member's would put a false location in the trail.
|
||||
- **The acceptance stays in the student's name** (`accepted_by`) — they did agree,
|
||||
on paper or over the phone. `recorded_by` is who entered it, which is a
|
||||
different question and gets a different column.
|
||||
|
||||
A weekly reservation is answered for once, so a recording made against any
|
||||
occurrence lands on the series anchor (`Lesson::intakeRegistrationId()`) and
|
||||
shows on every occurrence — the same rule the display side already follows.
|
||||
|
||||
The whole mechanism is shared with group-class enrolments, which have the same
|
||||
gap for the same reason; see **Recording Intake Collected Elsewhere** in
|
||||
`group-classes.md`.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
@@ -75,29 +224,48 @@ active `private_lesson` offerings whose `duration_minutes` matches the slot.
|
||||
for students; the instructor's for callers with `manage_availability`), each
|
||||
with the slot's `start_dt`/`end_dt`.
|
||||
|
||||
It also returns **upcoming group-class sessions**, sorted in among the lessons by
|
||||
start time (`GroupClass\SessionSchedule`). A student gets every remaining session
|
||||
of every class they are enrolled in; an instructor gets every session of the
|
||||
classes they teach. These rows carry `kind: "group_class"` — a session is a date
|
||||
in a term rather than a booked slot, so `booking.js` labels it and gives it no
|
||||
Cancel button. Lesson rows carry no `kind`, and that absence is what marks them
|
||||
cancellable.
|
||||
|
||||
Group classes follow the same registration flow but enrol against an offering of
|
||||
kind `group_class`; see `group-classes.md`.
|
||||
|
||||
## Admin Interface
|
||||
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors
|
||||
- **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
|
||||
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors, plus the **Book a lesson for a student** panel (see below), which reaches every instructor's open times
|
||||
- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor, plus the same **Book a lesson for a student** panel scoped to their own open times. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
|
||||
|
||||
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
|
||||
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`)
|
||||
- `[us_student_login]` — front-end login form for students
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`)
|
||||
- Booking core: `Unsupervised\Schedular\Booking\LessonBooker` — `resolveOffering()` (which offering a slot may be booked as), `reserve()` (claim the slot(s), write the lesson row(s)), `settle()` (raise the payment, or confirm when nothing is owed). Shared by `BookingEndpoint` and `AdminBooking` so the two paths cannot drift on price, payment routing, or double-booking.
|
||||
- Admin booking: `Unsupervised\Schedular\Booking\AdminBooking` — `book()` (guards, then the booker) and `formData()` (the panel's student / time / lesson-type choices)
|
||||
- Late intake: `Unsupervised\Schedular\Registration\IntakeRecording` — `pending()` (what is still unrecorded) and `record()` (the staff-registered guard, the dedup, then `RegistrationGate::record()` with an `IntakeProvenance`). Generic over `Registration\IntakeSubject`, which `Booking\Lesson` and `GroupClass\Enrollment` both implement
|
||||
- Provenance: `Unsupervised\Schedular\Registration\IntakeProvenance` — the collection-method vocabulary, its validation, and how a stored row reads on screen. Persisted as `collected_via` / `collected_note` / `recorded_by` on both `us_question_answers` and `us_policy_acceptances`; all null/0 for anything given online.
|
||||
- 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\Registration\IntakeAudit` (a registration's intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. Shared with the group-class enrolment detail view. A weekly series is answered for and agreed to once, against the anchor lesson, so `Lesson::intakeRegistrationId()` reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
||||
|
||||
> **Payment seam:** a priced booking is created with `status = pending` and its
|
||||
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
||||
@@ -105,7 +273,28 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
||||
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
||||
> must collect.
|
||||
>
|
||||
> **Frontend CSS scoping:** every rule for the booking page's own markup is
|
||||
> written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit
|
||||
> inside whatever layout the active theme provides, and bare class selectors lose
|
||||
> to theme rules on `div`/`span`/`strong` — which flattens the flex layout and
|
||||
> renders the lesson details on top of the actions. The row's two columns are
|
||||
> `div`s for the same reason: the layout must not depend on overriding the
|
||||
> inline default. New booking-page rules should follow both conventions.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/AdminBookingTest.php`
|
||||
- `tests/Unit/Registration/IntakeRecordingTest.php`, `tests/Unit/Registration/IntakeAuditTest.php`
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
- `tests/Unit/Booking/LessonTest.php`
|
||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
## Booking For Someone Else
|
||||
A guardian books for their children from their own account. `POST /bookings`
|
||||
accepts an optional **`student_id`**, honoured only when
|
||||
`Guardian\GuardianService::canActFor()` confirms the caller is that student's
|
||||
guardian — anything else is a `403`. The booking form's "Who is this for?" picker
|
||||
lists **children first**, so the default selection is never the parent.
|
||||
`GET /bookings` returns the whole household, and a guardian may cancel any of
|
||||
their children's lessons. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -15,12 +15,14 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
| `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
|
||||
| `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`) |
|
||||
@@ -30,6 +32,17 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
## Billing Mode
|
||||
- `one_time` — charged once at booking (a single private lesson).
|
||||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||||
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for that month. A **private lesson**'s price is a per-lesson fee, so the month is billed (#lessons in the month) × fee; a **group class**'s price is the monthly fee itself, billed once for the month however many times the class meets in it.
|
||||
|
||||
Students see the mode as a **cadence** beside every price on the front end — *at
|
||||
booking*, *up front*, *weekly*, *monthly* — and confirm it explicitly before a
|
||||
booking or enrolment goes through. See **Price Display and the Pay Agreement** in
|
||||
`payments.md`.
|
||||
|
||||
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||
daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`.
|
||||
|
||||
## Term Dates
|
||||
Group classes carry a term: `term_start` is the date of the first class and
|
||||
@@ -51,6 +64,30 @@ one-off), and returns an empty list unless date, time, and a positive duration a
|
||||
all set. These windows drive availability reconciliation (see **Instructor
|
||||
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
|
||||
@@ -75,7 +112,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|---------------------------------------------|----------------------------------|
|
||||
| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) |
|
||||
| `GET` | `/wp-json/us-scheduler/v1/offerings` | `book_lesson` or `manage_offerings` (active offerings only) |
|
||||
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
|
||||
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||||
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||||
@@ -84,7 +121,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
|
||||
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
|
||||
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`)
|
||||
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
|
||||
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# Feature: Parent/Guardian Accounts
|
||||
|
||||
## Overview
|
||||
A parent or guardian registers **once** and manages lessons for **one or more
|
||||
children**, without each child needing their own login. The guardian signs in,
|
||||
picks which child a booking is for, and pays for all of them from one account.
|
||||
|
||||
A guardian may also be a student in their own right — they appear in their own
|
||||
"who is this for?" selector alongside their children, so a parent taking lessons
|
||||
next to their kids needs only the one account.
|
||||
|
||||
## Vocabulary: "child" in the code, "student" in the UI
|
||||
|
||||
The interface says **student** and **profile**; the code says **child** and
|
||||
**family**. This is deliberate, not drift. Every identifier below — the
|
||||
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
|
||||
request parameters, the `child_name` form fields, the `us-scheduler/family`
|
||||
block name and the `[us_family]` shortcode — is a stable contract with the
|
||||
database, saved post content and existing installs, so renaming them would break
|
||||
sites for no user-visible gain. Only the strings a person reads were changed.
|
||||
|
||||
When adding to this feature, keep the split: internal names follow the
|
||||
data model, translatable strings follow the interface.
|
||||
|
||||
## Core Decision: children are accountless WordPress users
|
||||
|
||||
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
|
||||
`us_credits`, `us_group_enrollments`, `us_question_answers`,
|
||||
`us_policy_acceptances`, `us_group_access`) is a `wp_users` id, and booking,
|
||||
billing, credits, policies and registration answers all resolve it directly.
|
||||
|
||||
Rather than change what `student_id` means, **a child is a real `wp_users` row**
|
||||
with the `us_student` role, created without a usable login:
|
||||
|
||||
- no password (`wp_generate_password()` is used and discarded — nothing is ever
|
||||
emailed, so it cannot be guessed into a session),
|
||||
- no real email address; a child gets a placeholder login on the RFC 2606
|
||||
reserved `.invalid` TLD (`us-child-<random>@child.invalid`, see
|
||||
`GuardianService::childEmail()`) — a well-formed address that can never
|
||||
resolve, so nothing about a child's account can be emailed somewhere real,
|
||||
- the `us_child` user meta flag set to `1`, which
|
||||
`Guardian\ChildLoginGate` uses to block authentication outright.
|
||||
|
||||
Consequences:
|
||||
|
||||
- `us_lessons`, `us_group_enrollments`, `us_question_answers` and
|
||||
`us_group_access` are **unchanged** — a child books like any other student.
|
||||
- A child can be promoted to their own login later by setting a password and a
|
||||
real email and clearing `us_child`; no data migrates.
|
||||
- A `us_guardians` link table maps guardian → child.
|
||||
|
||||
The alternative — a standalone `us_students` table decoupled from `wp_users` —
|
||||
was rejected for v1: it changes the meaning of `student_id` on seven tables and
|
||||
requires migrating every existing row.
|
||||
|
||||
## Data Model — `{prefix}us_guardians`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|----------------|-----------------|-----------------------------------------------------------|
|
||||
| `id` | BIGINT UNSIGNED | Primary key |
|
||||
| `guardian_id` | BIGINT UNSIGNED | WordPress user ID of the parent/guardian |
|
||||
| `student_id` | BIGINT UNSIGNED | WordPress user ID of the child |
|
||||
| `relationship` | VARCHAR(50) | Free text shown in admin (e.g. "Parent", "Grandparent"); may be empty |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
`UNIQUE KEY guardian_student (guardian_id, student_id)` — the same pair can
|
||||
never be linked twice.
|
||||
|
||||
The table is a link table, not a child record: the child's **name** is their
|
||||
`display_name` on `wp_users`, and their birth year is the `us_birth_year`
|
||||
user meta. Keeping them on the user row means the admin student screens,
|
||||
`get_users()` ordering, and every existing `student_id` lookup keep working with
|
||||
no special-casing.
|
||||
|
||||
### The legacy `us_date_of_birth` meta
|
||||
|
||||
This feature originally collected a full date of birth in `us_date_of_birth`.
|
||||
Nothing writes that key any more. It is handled entirely inside
|
||||
`GuardianService`:
|
||||
|
||||
- **Read** — `birthYear()` falls back to the year of the old date when
|
||||
`us_birth_year` is absent, so a child added before the change still shows one
|
||||
without a migration step.
|
||||
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
|
||||
including a save that clears the year. Without that the fallback would
|
||||
resurrect the old date on the next read and the year could never be cleared.
|
||||
|
||||
The upshot is a lazy migration: a child's full date survives until their record
|
||||
is next edited, then goes for good. There is no bulk purge — a site that wants
|
||||
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
|
||||
|
||||
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
|
||||
refuses to link a child that already has a guardian. The unique key and the
|
||||
guardian-side lookups already support many-to-many, so adding a second guardian
|
||||
(separated parents) later is an insert, not a migration.
|
||||
|
||||
## Schema changes to existing tables
|
||||
|
||||
| Table | Change | Why |
|
||||
|---|---|---|
|
||||
| `us_payments` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Who owes the money, when that is not the student |
|
||||
| `us_credits` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Which account holds the balance |
|
||||
| `us_policy_acceptances` | `accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0` | Who actually clicked, when that is not the student |
|
||||
|
||||
All three default to `0`, read back as "same as `student_id`" (see
|
||||
`Payment::payerOrStudent()`, `Credit::payerOrStudent()`,
|
||||
`PolicyAcceptance::acceptorOrStudent()`), so **an existing row keeps its current
|
||||
meaning whatever happens** — a pre-guardian payment is still owed by, and was
|
||||
still accepted by, the student it names.
|
||||
|
||||
The installer additionally backfills them (`PaymentRepository::backfillPayerIds()`,
|
||||
`CreditRepository::backfillPayerIds()`, `AcceptanceRepository::backfillAcceptedBy()`,
|
||||
run from `Installer::migrateData()`), because the *balance* lookups key on
|
||||
`payer_id` directly and an indexed `WHERE payer_id = 5` would not see a legacy row
|
||||
still holding `0`. The backfill is idempotent — it only touches rows still at `0` —
|
||||
and the `payerOrStudent()` fallbacks remain as the belt to its braces.
|
||||
|
||||
## Billing: the guardian is the payer, the child is the subject
|
||||
|
||||
- `us_payments.student_id` keeps naming **the child the lesson was for**, so
|
||||
per-child payment reporting is unchanged.
|
||||
- `us_payments.payer_id` names **the guardian who owes it**. Payment notices,
|
||||
receipts and the Stripe intent all resolve the payer.
|
||||
- `us_credits.payer_id` is where a **family balance** lives. A credit from one
|
||||
child's cancelled lesson is held by the guardian and can settle a sibling's
|
||||
charge; `CreditRepository::availableBalance()` and `consume()` operate on the
|
||||
payer.
|
||||
- The **billing-method override** (`comp` / `card` / `etransfer`, user meta read
|
||||
by `BillingMethodResolver`) resolves against the payer, so comping a family is
|
||||
one setting on the guardian rather than one per child.
|
||||
|
||||
`PaymentService::createForRegistration()` takes the payer id alongside the
|
||||
student id; `BookingEndpoint` and `ScheduledBillingRunner` both pass
|
||||
`GuardianService::payerFor( $studentId )` — the child's guardian when they have
|
||||
one, otherwise the student themselves.
|
||||
|
||||
Family discounts are **out of scope** for v1 but are not designed out: with the
|
||||
payer on both the payment and the credit ledger, a discount rule has a family to
|
||||
apply to.
|
||||
|
||||
## Registration
|
||||
|
||||
A **"Who are you registering?"** choice on the existing `[us_student_register]`
|
||||
form (all three signup paths — personal invite, group link, self-approval), as
|
||||
three radios:
|
||||
|
||||
| Choice | `us_registering_for` | Student blocks | Account holder is a student | Answers the studio's questions |
|
||||
|---|---|---|---|---|
|
||||
| Just myself | `self` | no | yes | for themselves |
|
||||
| On behalf of one or more students | `students` | yes | **no** | per student only |
|
||||
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
|
||||
|
||||
The last column follows from the third, and is the whole of it: the
|
||||
account-scope questions describe a *student* — instrument, level, school — so
|
||||
they are asked of everyone being registered as one. Under `both` that is each
|
||||
student **and** the account holder, whose answers are stored against their own
|
||||
user id, not shared with anyone. Under `students` the account holder is not a
|
||||
student, so anything posted for them is ignored outright.
|
||||
|
||||
Required answers are checked in two passes rather than one, so the error can say
|
||||
whose are missing: `both` would otherwise have to blame "each student" for the
|
||||
account holder's own blank field.
|
||||
|
||||
Radios rather than checkboxes because the three answers are mutually exclusive:
|
||||
"both" only means anything as a third choice alongside the other two. Either
|
||||
student-bearing choice requires at least one student name.
|
||||
|
||||
Anything unrecognised — a form posted without the field, an old cached page, a
|
||||
crafted request — is read as `self`, the choice that collects the least and
|
||||
grants the least. A missing radio must never be taken as "register these
|
||||
children".
|
||||
|
||||
### The account holder as a student
|
||||
|
||||
This replaced a single "I'm registering as a parent or guardian" checkbox, which
|
||||
could only say *whether there were children to add*. It could not say whether the
|
||||
**account holder** was a student, so `bookableStudents()` always offered them
|
||||
their own name and every guardian could book themselves a lesson nobody intended
|
||||
to sell.
|
||||
|
||||
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
|
||||
account holder out. The flag is stored as the **negative** deliberately: every
|
||||
account predating the choice is a bookable student, and absence has to keep
|
||||
meaning exactly that, or the picker would silently stop offering people
|
||||
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
|
||||
than writing `0`, so "not set" stays the one spelling of "yes, a student".
|
||||
|
||||
One guard: a guardian-only account with **nobody linked to it** is still offered
|
||||
itself, because an empty picker is no way to book at all. They can put the
|
||||
account right from the profile page — see **Your details** below, where the flag
|
||||
is editable as "I take lessons myself".
|
||||
|
||||
Per child the form collects:
|
||||
- **Name** (required)
|
||||
- **Birth year** (required, `us_birth_year` meta) — a four-digit year between
|
||||
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
|
||||
definition of what counts, shared by the signup form's up-front validation and
|
||||
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
|
||||
than quietly discarded and a typo cannot leave a nonsense age on the record.
|
||||
- **Every account-scope registration question** (`Registration\Question`,
|
||||
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
|
||||
practice they describe the student (instrument, level, school). The guardian
|
||||
answers them on the child's behalf; the answer row's `student_id` is the child.
|
||||
|
||||
### What the account holder gives when they are a student
|
||||
|
||||
Under `self` and `both` the account holder is a student too, so the **About you**
|
||||
panel asks them for exactly the same two things every other student gives: their
|
||||
**birth year** (`us_birth_year`, the same meta key and the same
|
||||
`normaliseBirthYear()` rule — `GuardianService::setBirthYear()` writes both cases)
|
||||
and the **account-scope questions**. Both are stored against their own user id.
|
||||
|
||||
The panel sits on the main form, above the students, rather than behind a "Next".
|
||||
The questions used to be a second step, which put what the studio needs to know
|
||||
about an adult student on a screen they reached only after everything else; now
|
||||
one page holds one decision each — who you are registering, about you, about
|
||||
them.
|
||||
|
||||
`register.js` takes the whole **About you** fieldset out of play under
|
||||
`students`, by `disabled` as well as `hidden`: a disabled fieldset is neither
|
||||
validated nor submitted, so a `required` field cannot block a form on a control
|
||||
nobody can reach. The students block is toggled the same way, and the server
|
||||
enforces both rules regardless — which is what makes them hold with JavaScript
|
||||
off. The profile screen has no such problem: its forms are always visible, so the
|
||||
attribute is static there.
|
||||
|
||||
Name and birth year are marked required in the labels the same way a required
|
||||
question is; `[data-us-child-required]` keeps the attribute on the child fields
|
||||
in step with the block they live in.
|
||||
|
||||
Order of operations in `RegistrationPage::handleSubmit()`:
|
||||
|
||||
1. Validate the account holder's own fields (email, password, policies, and —
|
||||
when they are a student — their birth year and answers).
|
||||
2. Validate **every** child block — a missing name, a missing or unusable birth
|
||||
year, or a missing required per-child answer fails the whole submission
|
||||
**before** any user is created, so a half-registered family is never left
|
||||
behind. An **entirely empty** block is dropped instead, because the form
|
||||
always renders one spare for "add another"; a block with anything at all
|
||||
typed into it is kept and reported on, rather than silently discarding what
|
||||
the guardian entered.
|
||||
3. Create the guardian user, and record `us_guardian_only` and (when they are a
|
||||
student) their birth year against it.
|
||||
4. For each child: create the accountless user, link it, record its answers, and
|
||||
record the signup policy acceptances **against the child** with
|
||||
`accepted_by = <guardian>`.
|
||||
5. Roll back — every child user created so far is deleted and the guardian user
|
||||
with them — if any child creation fails, so a partial family never persists.
|
||||
|
||||
A guardian who does not tick the box registers exactly as before; nothing about
|
||||
the single-student flow changes.
|
||||
|
||||
### Policy acceptance
|
||||
|
||||
`us_policy_acceptances` records **one row per child** for each signup-scoped
|
||||
policy, with:
|
||||
|
||||
- `student_id` = the child (who the policy binds),
|
||||
- `accepted_by` = the guardian (who actually agreed),
|
||||
- `registration_type = 'account'`, `registration_id` = the child's user ID.
|
||||
|
||||
The guardian also gets their own acceptance row (`student_id = accepted_by =
|
||||
guardian`) whether or not they book for themselves — they agreed to the terms as
|
||||
an account holder. This is the legally meaningful record: "guardian X accepted
|
||||
policy version N on behalf of child Y at time T from IP Z".
|
||||
|
||||
Booking-scope policies are accepted at booking time by whoever is signed in;
|
||||
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
||||
child.
|
||||
|
||||
## Managing the account
|
||||
|
||||
### Your details
|
||||
|
||||
The profile screen opens with the account holder's own record, because the
|
||||
alternative was a page called **Your profile** on which the one person who could
|
||||
not be edited was you. The form saves through
|
||||
`GuardianService::updateSelf()` and holds:
|
||||
|
||||
- **Your name** — `display_name` and `nickname`, written together for the reason
|
||||
`updateChild()` does: `UserName` reads the nickname first, and leaving it
|
||||
behind would put the account's email address back on every screen that names a
|
||||
person.
|
||||
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
|
||||
asks the question the way a person answers it and `updateSelf()` is the one
|
||||
place the sense is flipped. This is what makes good on "they can put the
|
||||
account right from the profile page": an account that registered as a pure
|
||||
guardian and later took up lessons — or ticked the wrong radio at signup — can
|
||||
now correct itself instead of asking the studio to.
|
||||
- **Your birth year** — `us_birth_year`, the same meta and the same
|
||||
`normaliseBirthYear()` rule every student is held to.
|
||||
|
||||
Two decisions worth keeping:
|
||||
|
||||
- The birth-year field carries **no `required` attribute**. It is asked of a
|
||||
student only, and the family screen loads no JavaScript, so a browser-enforced
|
||||
`required` would leave a guardian who books solely for other people unable to
|
||||
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
|
||||
checkbox instead, which is where the condition actually lives.
|
||||
- Unticking **I take lessons myself** does **not** clear a stored birth year.
|
||||
The box says who books, not "forget what you know about me", and someone who
|
||||
ticks it back on the next visit should find their details as they left them.
|
||||
|
||||
The **email** is shown but not editable: it is the account's `user_login` as
|
||||
well as its address, so changing it is a studio-side job rather than a
|
||||
profile-screen one.
|
||||
|
||||
The account-scope questions are not re-asked here, in either direction — the
|
||||
child rows do not offer them on edit either, and a studio that needs a newly
|
||||
self-declared student's answers asks for them the same way it would for any
|
||||
other change of circumstance.
|
||||
|
||||
### Managing children
|
||||
|
||||
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
|
||||
list the children, add one, edit a name/birth year, remove one.
|
||||
|
||||
- **Add** creates another accountless child user and links it. Account-scope
|
||||
questions are asked here too, so a child added later carries the same
|
||||
information as one added at signup.
|
||||
- **Edit** updates `display_name` and `us_birth_year`.
|
||||
- **Remove** unlinks the child and **deletes the child user**, but only when the
|
||||
child has no lessons and no enrolments — a child with history is refused, so
|
||||
removing one can never orphan a lesson, payment or credit
|
||||
(`GuardianService::removeChild()`). The guardian is told to contact the studio
|
||||
instead.
|
||||
|
||||
Submissions are processed on `template_redirect` (like registration) and
|
||||
post/redirect/get back to the page, so a refresh cannot resubmit.
|
||||
|
||||
## Booking
|
||||
|
||||
`GET /bookings` returns the lessons of the signed-in user **and of every child
|
||||
they are guardian for**, each row carrying `student_id` and `student_name` so
|
||||
the list can be grouped by child.
|
||||
|
||||
`POST /bookings` takes an optional **`student_id`**:
|
||||
|
||||
- absent or `0` → the current user books for themselves (unchanged),
|
||||
- a child's id → the endpoint verifies with `GuardianService::canActFor()` that
|
||||
the current user is that child's guardian, and returns `403 forbidden` when
|
||||
they are not. **This is the authorisation boundary of the feature**: without
|
||||
it any student could book, and bill, against any user id they cared to send.
|
||||
|
||||
The booking form gains a "Who is this for?" `<select>`, rendered only when the
|
||||
account has more than one person on it, so a single-student account's form is
|
||||
unchanged.
|
||||
|
||||
**Children are listed first and the account holder last**
|
||||
(`GuardianService::bookableStudents()`). The order is the whole point: a
|
||||
guardian's normal case is booking for a child, so the default selection — the
|
||||
one a parent gets by not touching the picker at all — is a child, never
|
||||
themselves. Booking for the wrong child is a correctable inconvenience; silently
|
||||
billing a parent's account for a lesson meant for their kid is the error worth
|
||||
designing out. The guardian is still offered, last, so a parent taking lessons
|
||||
alongside their children can book for themselves.
|
||||
|
||||
The list is rendered server-side into `data-students` on the page wrapper and
|
||||
read by `assets/js/guardian.js`, which both the booking and group-class scripts
|
||||
share.
|
||||
|
||||
`POST /bookings/<id>/cancel` accepts a cancellation from the lesson's student
|
||||
**or** their guardian, subject to the same cancellation cutoff.
|
||||
|
||||
## Group classes
|
||||
|
||||
`POST /enrollments` carries the same optional `student_id` and the same
|
||||
`canActFor()` check, `GET /enrollments` covers the household, and
|
||||
`POST /enrollments/<id>/withdraw` accepts the guardian — group enrolment is the
|
||||
other place a family books and pays, so it gets the identical treatment rather
|
||||
than being left as a single-student-only path.
|
||||
|
||||
## Admin
|
||||
|
||||
- **Students list** gains a **Profile** column: a child links to its
|
||||
guardian's detail screen, a guardian lists its children as links. Children are
|
||||
listed alongside every other student rather than nested, so nothing about
|
||||
finding a student changes.
|
||||
- **Student detail** gains a **Profile** panel — the guardian (for a child) or
|
||||
the children (for a guardian), each a link to the other's screen — and the
|
||||
credit balance shown is the **payer's** balance, labelled with whose it is, so
|
||||
an admin looking at a child sees the family balance that will actually settle
|
||||
their charges rather than an empty per-child one.
|
||||
- Registration answers and policy acceptances on a child's screen show
|
||||
"accepted by <guardian>" where the acceptor differs from the student.
|
||||
|
||||
Creating or attaching a child from wp-admin is **out of scope** for v1; a studio
|
||||
admin adds children through the guardian's own family screen or asks the
|
||||
guardian to.
|
||||
|
||||
## Capabilities
|
||||
|
||||
No new capability. A child user holds the `us_student` role (so every existing
|
||||
`student_id` capability check keeps working) but can never sign in
|
||||
(`Guardian\ChildLoginGate` blocks `wp_authenticate_user` and forces
|
||||
`user_has_cap` to withhold `book_lesson` from a child), so the role grants them
|
||||
nothing in practice. Guardians act for children through
|
||||
`GuardianService::canActFor()`, checked at every REST and form boundary, rather
|
||||
than through a capability.
|
||||
|
||||
## Instructor view
|
||||
|
||||
Lesson lists show the student's name. Where that student is a child, the
|
||||
instructor also sees the guardian's name and email — the contact they actually
|
||||
need — via `GuardianService::contactFor()`.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
||||
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
||||
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
||||
`canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
|
||||
holder's own record via `accountHolder()`/`updateSelf()`)
|
||||
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
||||
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
||||
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
||||
(one question rendered under a caller-supplied input name, so the same
|
||||
question can appear once per child without colliding)
|
||||
- Front-end script: `assets/js/guardian.js` (the shared picker),
|
||||
`assets/js/register.js` (guardian toggle + "add another child")
|
||||
- Extended: `Auth\RegistrationPage` (guardian checkbox, child blocks, per-child
|
||||
answers/acceptances, rollback), `Booking\BookingEndpoint` and
|
||||
`GroupClass\EnrollmentEndpoint` (`student_id` param + guardian
|
||||
authorisation, household listings), `Booking\BookingPage`,
|
||||
`GroupClass\GroupClassPage`, `Payment\PaymentService`,
|
||||
`Payment\PaymentRepository`, `Payment\CreditRepository`,
|
||||
`Payment\ScheduledBillingRunner` (one notice per payer),
|
||||
`Policy\AcceptanceRepository`, `Registration\RegistrationGate`,
|
||||
`Auth\StudentController`, `Installer` (backfills)
|
||||
- Schema: `us_guardians`; `us_payments.payer_id`; `us_credits.payer_id`;
|
||||
`us_policy_acceptances.accepted_by`
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/Unit/Guardian/GuardianLinkTest.php`
|
||||
- `tests/Unit/Guardian/GuardianRepositoryTest.php`
|
||||
- `tests/Unit/Guardian/GuardianServiceTest.php`
|
||||
- `tests/Unit/Guardian/ChildLoginGateTest.php`
|
||||
- `tests/Unit/Guardian/FamilyPageTest.php`
|
||||
- `tests/Unit/Auth/RegistrationPageTest.php` (guardian signup path)
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` and
|
||||
`tests/Unit/GroupClass/EnrollmentEndpointTest.php` (booking/enrolling for a
|
||||
child, and the 403 when the caller is not the guardian)
|
||||
- `tests/Unit/Booking/BookingPageTest.php` (children lead the embedded list)
|
||||
- `tests/Unit/Payment/PaymentServiceTest.php`, `CreditRepositoryTest.php`,
|
||||
`ScheduledBillingRunnerTest.php` (payer, family balance, one notice)
|
||||
|
||||
## Related
|
||||
|
||||
`account-registration.md`, `lesson-booking.md`, `payments.md`, `credits.md`,
|
||||
`group-classes.md`, `student-administration.md`, `policies.md`,
|
||||
`registration-questions.md`.
|
||||
+112
-2
@@ -34,6 +34,15 @@ page (`manage_billing`, studio admin only):
|
||||
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
|
||||
| `us_etransfer_email` | Studio-default e-transfer destination |
|
||||
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
|
||||
| `us_default_payment_method` | Studio-default billing method (`card` \| `etransfer`) |
|
||||
|
||||
Secrets are write-only in the form: a stored secret is never echoed back, and a
|
||||
blank field keeps it. To disconnect Stripe entirely, **Clear Stripe
|
||||
configuration** (shown once any Stripe value is stored) deletes the publishable
|
||||
key, secret key and webhook secret and drops the mode back to `test`
|
||||
(`StudioSettings::clearStripeConfig()`). Currency, HST, e-transfer and
|
||||
registration settings are untouched, as are payments already recorded; billing
|
||||
falls back to e-transfer until keys are entered again.
|
||||
|
||||
## HST / Tax
|
||||
|
||||
@@ -52,8 +61,9 @@ total when tax applies.
|
||||
## Per-Student Billing Method
|
||||
Each student's billing method is stored in user meta `us_payment_method`, set by the
|
||||
studio admin (`Students → student detail → Billing method`). When unset, the studio
|
||||
default applies — `card` if Stripe is configured, otherwise `etransfer`
|
||||
(`BillingMethodResolver`):
|
||||
default applies (`BillingMethodResolver::defaultMethod()`): the
|
||||
`us_default_payment_method` option, degraded to `etransfer` whenever Stripe is not
|
||||
configured, since a card cannot be charged without keys.
|
||||
|
||||
| Method | Behaviour |
|
||||
|------------|-----------------------------------------------------------------------|
|
||||
@@ -61,6 +71,22 @@ default applies — `card` if Stripe is configured, otherwise `etransfer`
|
||||
| `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive |
|
||||
| `comp` | No charge; registration is confirmed immediately, no payment row required |
|
||||
|
||||
## Studio Default Billing Method
|
||||
**Studio Settings → Billing → Default payment method** (`manage_billing`) chooses
|
||||
between `card` and `etransfer` for every student without an override. Card is the
|
||||
default, so a studio that adds Stripe keys and changes nothing else behaves as it
|
||||
always has.
|
||||
|
||||
Setting it to `etransfer` is the **staged rollout** path: Stripe stays live, but
|
||||
the studio keeps billing by e-transfer while individual students are switched to
|
||||
`card` on their student detail page. Their bookings exercise real Stripe charges
|
||||
end to end; once that is proven, flipping the studio default to `card` moves
|
||||
everyone at once and the per-student overrides can be cleared.
|
||||
|
||||
`comp` is deliberately not offered as a studio default — it is a per-student
|
||||
decision, and a studio-wide `comp` would silently stop billing everybody. A stored
|
||||
value that is neither `card` nor `etransfer` reads back as `card`.
|
||||
|
||||
## E-transfer Destination Email
|
||||
Where students send e-transfers is resolved and **frozen onto the payment** at
|
||||
booking time (`us_payments.etransfer_email`), so each record keeps the destination
|
||||
@@ -88,6 +114,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` |
|
||||
@@ -95,6 +124,59 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||
|
||||
## Price Display and the Pay Agreement
|
||||
Every price a student is shown on the front end carries its **cadence** — the
|
||||
offering's `billing_mode` in the words the student needs:
|
||||
|
||||
| `billing_mode` | Shown as | Explained beneath as |
|
||||
|----------------|-----------------------------------|------------------------------------------------------------|
|
||||
| `one_time` | `at booking` | Charged once, when you book. |
|
||||
| `full_term` | `up front` | Charged once, up front, for the whole term. |
|
||||
| `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. |
|
||||
| `monthly` | `per lesson monthly` / `monthly` | Charged on the 1st of each month, for that month's lessons.|
|
||||
|
||||
So a lesson type reads `50.00 CAD at booking` in the booking form's type picker,
|
||||
and a group class card reads `120.00 CAD up front`. A free offering shows `Free`.
|
||||
|
||||
**`monthly` reads differently per offering kind, because it *bills* differently.**
|
||||
A private lesson's price is a per-lesson fee and its monthly charge is that
|
||||
month's lessons × the fee, so the fee is quoted **per lesson**
|
||||
(`50.00 CAD per lesson monthly`). A monthly group class is priced **per month** —
|
||||
`ScheduledBillingRunner::billGroupMonthly()` charges the fee once for the month
|
||||
however many times the class meets in it — so its figure is quoted as it stands
|
||||
(`120.00 CAD monthly`). The display split is `isPerLessonMonthly()` in
|
||||
`assets/js/pricing.js`; the billing split is the one place the monthly rule
|
||||
differs between the two kinds.
|
||||
|
||||
Before a booking or enrolment can be submitted, the form shows the price again as
|
||||
a summary block with a **required agreement checkbox** — the second confirmation,
|
||||
distinct from the policy acceptances above it:
|
||||
|
||||
> ☐ I agree to pay 56.50 CAD at booking.
|
||||
|
||||
The agreed figure is the amount actually billed, so the studio **HST rate** is
|
||||
added to it (`usScheduler.taxRate`, localized from `us_hst_rate`) and broken out
|
||||
above the checkbox — matching the total `Payment::total()` charges. A comped
|
||||
student is not taxed and is not charged at all, so for them the quoted figure is
|
||||
an upper bound. A free offering has nothing to agree to and shows no block.
|
||||
|
||||
Cadence-specific wording:
|
||||
|
||||
- **Weekly reservation of a `one_time` lesson type** — the fee is charged once per
|
||||
week claimed, so the agreement states the per-lesson amount and the total as a
|
||||
ceiling ("up to 12 lessons, 678.00 CAD in total"). The occurrence count mirrors
|
||||
`BookingEndpoint::MAX_WEEKLY_OCCURRENCES`; a slot another student takes first is
|
||||
simply not claimed, so the real charge can come in under it.
|
||||
- **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is
|
||||
to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly."
|
||||
A monthly **group class** agrees to its monthly figure instead ("I agree to pay
|
||||
138.00 CAD monthly."), matching how its price is quoted on the card.
|
||||
|
||||
All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the
|
||||
booking and group-class flows so a price reads the same wherever it is met. The
|
||||
script is registered as `us-scheduler-pricing` and is a dependency of both
|
||||
`us-scheduler` and `us-scheduler-group`.
|
||||
|
||||
## Payment Flow
|
||||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||
2. The browser confirms the card payment with Stripe.
|
||||
@@ -102,6 +184,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 |
|
||||
|---------|---------------------------------------------|-----------------------------|
|
||||
@@ -118,9 +218,19 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
||||
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
||||
- Front-end price display + pay agreement: `assets/js/pricing.js` (`window.usPricing`), registered and localized with `taxRate` by `Unsupervised\Schedular\ShortcodeRegistrar`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||
- `tests/Unit/Payment/PaymentTest.php`
|
||||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||||
- `tests/Unit/Payment/ReceiptMailerTest.php`
|
||||
|
||||
## Who Pays
|
||||
`us_payments.student_id` names the student the charge is *for*;
|
||||
`us_payments.payer_id` names who **owes** it — a child's guardian, or 0 meaning
|
||||
the student pays for themselves (`Payment::payerOrStudent()`). The billing
|
||||
method, receipts, payment notices and the Stripe payment step all resolve the
|
||||
payer, so a family is billed and comped as one account while per-child reporting
|
||||
is unchanged. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -47,12 +47,31 @@ update for a same-slug plugin and makes core fire the
|
||||
`us_schedular_latest_release` transient for 6 hours.
|
||||
3. Strips the leading `v` from the tag and compares against `USC_VERSION`
|
||||
with `version_compare`; PHP orders `1.0.0-rc.2 < 1.0.0` correctly.
|
||||
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.
|
||||
4. When newer, returns the release's first `.zip` asset **whose download URL
|
||||
is `https` on `git.unsupervised.ca` itself** as the update package. Core
|
||||
takes over from there: Plugins-screen notice, one-click update, and
|
||||
WP-Cron auto-updates if enabled.
|
||||
|
||||
The host check is not ceremony. Whatever this returns is downloaded and
|
||||
unpacked over the installed plugin, so the URL is executable code by
|
||||
another name — and it arrives in a JSON body. An answer that is not really
|
||||
the release server's (a hijacked hostname, a tampered response, a repo host
|
||||
handing out a package hosted somewhere else) would otherwise install
|
||||
arbitrary code on every site running the plugin, silently for anyone with
|
||||
auto-updates on. The host must match exactly: `evil-git.unsupervised.ca`,
|
||||
`git.unsupervised.ca.evil.test` and `cdn.git.unsupervised.ca` are all
|
||||
refused, and so is plain `http`. An asset that fails the check is skipped
|
||||
and the scan continues, so one bad asset does not hide a good one.
|
||||
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:`
|
||||
|
||||
@@ -36,18 +36,30 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
|
||||
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
|
||||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
|
||||
| `accepted_at` | DATETIME | Timestamp of acceptance |
|
||||
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail) |
|
||||
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail); NULL when not given online |
|
||||
| `collected_via` | VARCHAR(20) | How the acceptance reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
|
||||
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
|
||||
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere acceptance in; 0 otherwise |
|
||||
|
||||
## Versioning & Acceptance Rules
|
||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
||||
- Editing a `draft` version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. `PATCH /policies/{id}/versions/{vid}` allows only this case; the admin page also accepts an edit to a `published` or `archived` version and branches a new draft from it.
|
||||
- Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it.
|
||||
- The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking.
|
||||
|
||||
## Admin Interface
|
||||
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
||||
- Create a policy; draft and edit version bodies
|
||||
- Create a policy; draft version bodies
|
||||
- **Rename** the selected policy (`rename_policy`, `PolicyRepository::updateTitle()`). Only the title changes: the slug is the identifier `findBySlug()` and the gates resolve policies by, so renaming can never detach a policy from versions students have already accepted. A blank title, or one longer than `Policy::MAX_TITLE_LENGTH`, is ignored
|
||||
- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status
|
||||
- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten
|
||||
- Publish a draft version; view acceptance history per version
|
||||
|
||||
## Rendering a Policy Body
|
||||
Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. `PolicyVersion::bodyHtml()` is the single render path — `wp_kses_post()` then `wpautop()`, the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (`GET /policies`), the signup form, and the admin version viewer, which therefore previews exactly what students see.
|
||||
|
||||
The acceptance markup (`.us-policy` / `.us-policy-body`) is styled in `assets/css/frontend.css` as a bounded, vertically scrolling reading box with `overflow-wrap: break-word`, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. `RegistrationPage` enqueues that stylesheet for the signup gate; `BookingPage` and `GroupClassPage` already did.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|-----------------------------------------------------------------|-------------------|
|
||||
@@ -74,3 +86,18 @@ cover every policy's current version or the registration is rejected.
|
||||
- `tests/Unit/Policy/PolicyVersionRepositoryTest.php`
|
||||
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
||||
- `tests/Unit/Policy/PolicyServiceTest.php`
|
||||
- `tests/Unit/Policy/PolicyControllerTest.php`
|
||||
- `tests/Unit/Policy/PolicyEndpointTest.php`
|
||||
|
||||
## Who Accepted
|
||||
`us_policy_acceptances.accepted_by` records the person who actually ticked the
|
||||
box, where that differs from `student_id` — a guardian agreeing on a child's
|
||||
behalf. It defaults to 0, read back as "the student agreed for themselves"
|
||||
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
|
||||
|
||||
`recorded_by` answers a different question: who *entered* the acceptance, for one
|
||||
collected on paper or over the phone and typed in afterwards. The student still
|
||||
agreed, so `accepted_by` stays theirs; `collected_via` says how, and no IP is
|
||||
stored because they were never at a browser. Only lessons the studio booked can
|
||||
be recorded against — see **Recording Intake Collected Elsewhere** in
|
||||
`lesson-booking.md`.
|
||||
|
||||
@@ -7,8 +7,8 @@ Questions come in two **scopes**:
|
||||
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.
|
||||
**once at account signup**, on the same page as 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**).
|
||||
@@ -23,11 +23,32 @@ and the same authoring page (**Offerings → Questions**).
|
||||
| `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 |
|
||||
| `audience` | VARCHAR(20) | `all` (default) or `child` — who the question is asked of (account scope) |
|
||||
| `is_required` | TINYINT(1) | 1 = the **account holder** must answer to continue |
|
||||
| `is_required_child` | TINYINT(1) | 1 = each **student being registered** must answer to continue |
|
||||
| `sort_order` | INT | Display order within the scope |
|
||||
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Audience and Required-ness (account scope)
|
||||
An account-scope question is asked in two places, and the two are configured separately:
|
||||
|
||||
- **The account holder's own "About you" panel** — shown when they are registering
|
||||
themselves (`self` or `both`). Governed by `audience` (a `child` question is not asked
|
||||
here at all) and by `is_required`.
|
||||
- **Each student block** — one per person they are registering on behalf of, on the signup
|
||||
form and on the guardian's family screen. Every question is asked here regardless of
|
||||
`audience`; `is_required_child` decides whether it blocks submission.
|
||||
|
||||
That split is what lets a studio ask "School and grade" of children only, or make
|
||||
"Previous experience" optional for an adult signing themselves up but required for every
|
||||
child they enrol. `audience = 'child'` leaves `is_required` moot — the question never
|
||||
reaches the account holder's panel.
|
||||
|
||||
`audience` and `is_required_child` are ignored for offering-scope questions: booking and
|
||||
enrolment ask their intake questions once, about the student being booked, with no separate
|
||||
account-holder form to differ from.
|
||||
|
||||
## Data Model — `{prefix}us_question_answers`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -36,6 +57,9 @@ and the same authoring page (**Offerings → Questions**).
|
||||
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.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) |
|
||||
| `collected_via` | VARCHAR(20) | How the answer reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
|
||||
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
|
||||
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere answer in; 0 otherwise |
|
||||
| `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 |
|
||||
@@ -49,21 +73,26 @@ lesson, a group enrolment, or an account signup (`account` + the user ID).
|
||||
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)
|
||||
## Account-scope Flow (signup)
|
||||
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>`.
|
||||
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added — minus any `audience = 'child'` question, which is never asked of the account holder. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the full question set in every child block instead. Progressive enhancement: without JS every panel shows 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) — `is_required` against the account holder's panel, `is_required_child` against each student block; after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. An answer posted for a `child`-audience question against the account holder is discarded, not stored.
|
||||
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
|
||||
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.
|
||||
- The account-scope form adds **Asked of** (everyone / students only) and a second **Required** checkbox for students; both are hidden for offering scope, where they have no meaning.
|
||||
- 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`.
|
||||
through the server-rendered admin page and read directly by `RegistrationPage` — a request
|
||||
naming one is turned away as not found, since the owner check has no offering to check
|
||||
against, so REST can neither read nor overwrite an `audience`. An offering question written
|
||||
over REST mirrors its single `is_required` into `is_required_child`, as the admin form and
|
||||
the upgrade backfill both do.
|
||||
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|---------------------------------------------------|----------------------|
|
||||
@@ -74,17 +103,30 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
|
||||
## Implementation
|
||||
- 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`)
|
||||
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`, `audience`, `isRequiredChild`, and the `askedOfSelf()` / `isRequiredForSelf()` / `isRequiredForChild()` readers every caller uses instead of touching `isRequired` directly), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
|
||||
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
|
||||
- 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`
|
||||
- Signup form: `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)
|
||||
- Schema: `us_questions.scope` + nullable `us_questions.offering_id`, `us_questions.audience`, `us_questions.is_required_child` (each requires a plugin version bump so `dbDelta` runs)
|
||||
- Required-for-students backfill: `is_required_child` arrives with `DEFAULT 0`, which would quietly make every existing required question optional for students. `QuestionRepository::backfillChildRequired()` copies `is_required` into it once; `Plugin::boot()` runs it guarded by the `us_questions_child_required_backfilled` option, after the version gate has let `dbDelta` add the column
|
||||
- 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/Registration/QuestionFieldTest.php`
|
||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php`
|
||||
- `tests/Unit/Guardian/FamilyPageTest.php`
|
||||
|
||||
## Per-Child Answers
|
||||
For a parent/guardian signup, **account-scope** questions are asked **once per
|
||||
child** rather than once per guardian — in practice they describe the student
|
||||
(instrument, level, school), not the account holder. Each answer's `student_id`
|
||||
and `registration_id` are the child's user ID, so a studio admin reading a
|
||||
child's screen sees the information that describes them. The guardian's family
|
||||
screen asks the same questions when a child is added later, under the same
|
||||
`is_required_child` rule as the signup form. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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. A **private lesson**'s fee is per lesson, so the
|
||||
month costs (#lessons) × fee. A **group class**'s fee is per month: the class is
|
||||
billed that fee once for the month, however many times it meets in it.
|
||||
|
||||
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 | 1 × fee (a monthly class is priced per month, not per session) | `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)
|
||||
|
||||
## One Notice Per Family
|
||||
Charges are bucketed by **payer**, not student, so a guardian gets a single
|
||||
notice covering every child rather than one email per child. Each line names the
|
||||
student it is for when that is not the payer ("Ada: Piano Lesson — Mar 3, 2026"),
|
||||
and account credit is applied across the whole bucket from the family balance.
|
||||
See `parent-guardian-accounts.md`.
|
||||
@@ -24,15 +24,27 @@ No new tables. The views are composed from existing data:
|
||||
quick counts (upcoming lessons, active group enrolments). Each row links to the
|
||||
detail view.
|
||||
- **Detail** (`?student_id=`):
|
||||
- **Account** — display name, email, registered date.
|
||||
- **Account** — display name, email, registered date, and **Booked by**: the
|
||||
name of the parent/guardian who books and pays for this student, linked to
|
||||
their own detail page. Always rendered — a student who books for themselves
|
||||
says so in words, so an empty row can never be mistaken for a lookup that
|
||||
failed.
|
||||
- **Upcoming lessons** and **Past lessons** — split by the linked availability
|
||||
slot's `start_dt`; each shows date/time, offering, instructor, and status.
|
||||
**Upcoming lessons** also lists the student's upcoming group-class sessions
|
||||
(`GroupClass\SessionSchedule`, marked "group class"), so one table answers
|
||||
"what are they booked into next week?". Only upcoming ones: past dates would
|
||||
bury the lessons, and the enrolment table below already holds the history.
|
||||
- **Group-class enrolments** — active/past, with offering title and status.
|
||||
- **Policy acceptances** — every acceptance the student has recorded, newest
|
||||
first: policy title, version, context (account signup / lesson / enrolment),
|
||||
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,9 +56,41 @@ 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.
|
||||
its capacity seat), with the same pending-payment voiding. This is the only way
|
||||
to remove a class; the group-class rows in **Upcoming lessons** carry no Cancel
|
||||
action, because there is no such thing as cancelling one session of a term.
|
||||
|
||||
## Deleting a user
|
||||
Deleting a WordPress user is a core action that knows nothing about lessons, so
|
||||
`Auth\DeletedUserCleanup` hooks `delete_user` (and `wpmu_delete_user`) and gives
|
||||
back what the account was holding: every **upcoming** lesson is marked
|
||||
`cancelled`, its availability slot released for rebooking, and its still-pending
|
||||
payment voided; every **active** group-class enrolment is cancelled and its
|
||||
pending payment voided. Without it the slots stayed marked booked and unbookable
|
||||
by anyone else, the lessons stayed on the instructor's schedule under a name that
|
||||
no longer resolved, and a class kept a seat filled by nobody.
|
||||
|
||||
**A guardian takes their children with them.** A child account is login-less and
|
||||
exists only so the guardian has somebody to book for; without the guardian nobody
|
||||
can reach it, book for it, or be billed for it, so leaving it behind leaves an
|
||||
unreachable student on the roster holding slots that will never be used. Each
|
||||
child's bookings are released on the same terms, the `us_guardians` link row is
|
||||
deleted, and the account goes. Deleting a child fires `delete_user` again and
|
||||
re-enters the same handler; a `handled` set of user ids makes that a no-op and
|
||||
also stops a self-referential or circular link recursing.
|
||||
|
||||
(This is a different rule from the family screen's **Remove**, which still refuses
|
||||
a child with any lesson or enrolment history — that is a guardian tidying up, not
|
||||
an admin deleting an account, and `GuardianService::removeChild()` is unchanged.)
|
||||
|
||||
Past lessons are deliberately untouched: they happened, they may have been paid
|
||||
for, and the payment report has to keep adding up. No account credit is issued
|
||||
for a paid lesson either, unlike a cancellation the student asks for — a credit
|
||||
can only be spent on the account being deleted, so a refund owed to someone who
|
||||
has left is the studio's decision to make and record.
|
||||
|
||||
## Capabilities
|
||||
- `manage_students` — studio admin (administrators inherit it via the
|
||||
@@ -68,6 +112,8 @@ All actions are nonce-protected POSTs handled on the detail page:
|
||||
refuse records that don't belong to the student, and reuse
|
||||
`Payment\PaymentService::voidPending`) and account updates via
|
||||
`wp_update_user` (unit-tested with mocked repositories).
|
||||
- Group-class sessions in the upcoming table: `GroupClass\SessionSchedule::upcomingForStudent()`
|
||||
- Deletion cleanup: `Auth\DeletedUserCleanup` (hooked in `Plugin::boot()`)
|
||||
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
|
||||
- The upcoming/past split is extracted into a small pure helper so it is
|
||||
unit-testable (the controller itself follows the repo convention of not being
|
||||
@@ -75,9 +121,17 @@ All actions are nonce-protected POSTs handled on the detail page:
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
|
||||
- `tests/Unit/Auth/DeletedUserCleanupTest.php` (release on user deletion)
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks)
|
||||
- `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side
|
||||
effects, account validation)
|
||||
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
|
||||
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
|
||||
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||
|
||||
## Family Relationships
|
||||
The students list gains a **Profile** column — a child links to their guardian,
|
||||
a guardian lists their children — and the student screen a **Profile** panel. A
|
||||
child's listed email is their guardian's, since a child's own address is an
|
||||
undeliverable placeholder, and the credit balance shown is the payer's, labelled
|
||||
with whose account holds it. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -3,6 +3,13 @@ includes:
|
||||
|
||||
parameters:
|
||||
level: 10
|
||||
# Analyse against the whole supported range, not whatever PHP happens to be
|
||||
# running. Without this, syntax newer than the `Requires PHP: 8.1` header
|
||||
# promises passes lint on a modern local PHP and only fails in the 8.1 test
|
||||
# job — which is how a PHP 8.2 `true` return type once reached CI.
|
||||
phpVersion:
|
||||
min: 80100
|
||||
max: 80300
|
||||
paths:
|
||||
- src
|
||||
bootstrapFiles:
|
||||
|
||||
+46
-6
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\InstructorController;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
@@ -14,16 +15,21 @@ use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\StudentActions;
|
||||
use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
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;
|
||||
@@ -37,9 +43,18 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\QuestionController;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
|
||||
class AdminMenu {
|
||||
|
||||
/**
|
||||
* Hook suffix of the availability screen, captured when the page is added so
|
||||
* its script loads on that screen only.
|
||||
*/
|
||||
private string $availabilityHook = '';
|
||||
|
||||
private AvailabilityController $availabilityController;
|
||||
private LessonController $lessonController;
|
||||
private OfferingController $offeringController;
|
||||
@@ -55,16 +70,21 @@ 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, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||
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, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) {
|
||||
// One audit presenter and one recorder, shared by the lesson and enrolment
|
||||
// detail views: intake is the same thing whichever registration it hangs off.
|
||||
$intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions );
|
||||
$intakeRecording = new IntakeRecording( $questions, $answers, $policies, $policyVersions, $acceptances, $gate );
|
||||
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
|
||||
$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( $registrationMailer );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer, $intakeAudit, $intakeRecording );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
|
||||
$this->instructorController = new InstructorController();
|
||||
$this->settings = $settings;
|
||||
$this->accessSettings = new AccessSettings();
|
||||
@@ -74,9 +94,29 @@ class AdminMenu {
|
||||
|
||||
public function register(): void {
|
||||
add_action( 'admin_menu', [ $this, 'addPages' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a screen's script on that screen only.
|
||||
*
|
||||
* @param string $hookSuffix Screen the enqueue is running for.
|
||||
*/
|
||||
public function enqueueAssets( string $hookSuffix ): void {
|
||||
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public function addPages(): void {
|
||||
$this->addStudioSeparators();
|
||||
|
||||
@@ -92,7 +132,7 @@ class AdminMenu {
|
||||
);
|
||||
|
||||
// Instructor: manage their own availability.
|
||||
add_menu_page(
|
||||
$this->availabilityHook = (string) add_menu_page(
|
||||
__( 'My Availability', 'unsupervised-schedular' ),
|
||||
__( 'My Availability', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||
|
||||
@@ -3,17 +3,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Uninstaller;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Site-owner toggles for whether WordPress administrators automatically receive
|
||||
* the studio-admin and/or instructor capabilities.
|
||||
* The site owner's page: whether WordPress administrators automatically receive
|
||||
* the studio-admin and/or instructor capabilities, and what deleting the plugin
|
||||
* takes with it.
|
||||
*
|
||||
* Both default on, preserving the out-of-the-box experience where a single
|
||||
* administrator runs the studio and teaches from one account. The settings page
|
||||
* is gated on `manage_options` (the core WordPress administrator capability,
|
||||
* which the plugin never grants or revokes) so an administrator can always reach
|
||||
* it to re-enable a grant — disabling one can never lock them out.
|
||||
* Both capability grants default on, preserving the out-of-the-box experience
|
||||
* where a single administrator runs the studio and teaches from one account. The
|
||||
* settings page is gated on `manage_options` (the core WordPress administrator
|
||||
* capability, which the plugin never grants or revokes) so an administrator can
|
||||
* always reach it to re-enable a grant — disabling one can never lock them out.
|
||||
*
|
||||
* The data-removal choice lives here for the same reason: `manage_options` is
|
||||
* held by exactly the people who can delete a plugin, so the switch and the act
|
||||
* it governs are in the same pair of hands. {@see Uninstaller} explains what the
|
||||
* two answers mean.
|
||||
*/
|
||||
class AccessSettings {
|
||||
|
||||
@@ -49,21 +56,56 @@ class AccessSettings {
|
||||
wp_die( esc_html__( 'You do not have permission to manage access settings.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_access_action' ) ) {
|
||||
$this->save();
|
||||
$error = $this->save();
|
||||
}
|
||||
|
||||
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
|
||||
$adminsAreInstructors = $this->adminsAreInstructors();
|
||||
$deleteDataOnUninstall = Uninstaller::deletesDataOnUninstall();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/access.php';
|
||||
}
|
||||
|
||||
private function save(): void {
|
||||
/**
|
||||
* Persist the submitted settings, reporting why the data-removal choice was
|
||||
* refused when it was. Everything else on the page saves either way: a
|
||||
* mistyped confirmation must not also swallow a capability change.
|
||||
*/
|
||||
private function save(): string {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
|
||||
update_option( self::OPT_GRANT_INSTRUCTOR, isset( $_POST['grant_instructor'] ) ? '1' : '0' );
|
||||
|
||||
$wanted = isset( $_POST['delete_data'] );
|
||||
|
||||
// Switching it off is not the dangerous direction, and needs no ceremony.
|
||||
if ( ! $wanted ) {
|
||||
Uninstaller::setDeletesDataOnUninstall( false );
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Already on and left on: this save is about something else on the page,
|
||||
// so do not make them retype the word to keep a setting they already made.
|
||||
if ( Uninstaller::deletesDataOnUninstall() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Turning it on erases records that cannot be got back, so the tick alone
|
||||
// is not enough — it is one stray click, and this is the only place in the
|
||||
// plugin where a stray click is unrecoverable.
|
||||
$confirmed = 'delete' === sanitize_key( Val::string( wp_unslash( $_POST['delete_data_confirm'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( ! $confirmed ) {
|
||||
return __( 'Data removal was not enabled: type DELETE in the confirmation box to turn it on. Everything else on this page was saved.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
Uninstaller::setDeletesDataOnUninstall( true );
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Who is signed in, and the way out.
|
||||
*
|
||||
* Meant for a header, sidebar or account page — somewhere it sits alongside
|
||||
* other content rather than being the whole of it. That shapes the two
|
||||
* decisions below.
|
||||
*/
|
||||
class AccountPage {
|
||||
|
||||
/**
|
||||
* Renders the account shortcode/block output.
|
||||
*
|
||||
* Signed out, this renders a sign-in link when a login page is configured and
|
||||
* **nothing at all** when one is not. A block whose whole job is "you are
|
||||
* signed in as X" has nothing to say to a stranger, and a bare "you are not
|
||||
* signed in" in a site header is noise with no way to act on it. The editor
|
||||
* preview shows the populated state regardless, so the block is never
|
||||
* invisible to the person placing it.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
*/
|
||||
public function render( array $atts ): string {
|
||||
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||
$loginUrl = $this->pageUrl( $loginPageId );
|
||||
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
if ( ! is_user_logged_in() ) {
|
||||
if ( null === $loginUrl ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
|
||||
esc_url( $loginUrl ),
|
||||
esc_html__( 'Sign in', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
// Always a WP_User here — is_user_logged_in() above rules out the
|
||||
// id-0 placeholder wp_get_current_user() returns for a visitor.
|
||||
$user = wp_get_current_user();
|
||||
|
||||
$name = UserName::format( $user, get_current_user_id() );
|
||||
$email = $user->user_email;
|
||||
|
||||
// Back to where they were, so signing out of a header link does not also
|
||||
// navigate them somewhere. The login page is the better landing spot when
|
||||
// one is configured, since the current page may be members-only.
|
||||
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permalink of a configured page, or null when none is chosen or the chosen
|
||||
* page has since been deleted.
|
||||
*/
|
||||
private function pageUrl( int $pageId ): ?string {
|
||||
if ( $pageId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = get_permalink( $pageId );
|
||||
|
||||
return is_string( $url ) ? $url : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
|
||||
/**
|
||||
* Gives back what a deleted account was holding, and takes the accounts that
|
||||
* only existed underneath it with it.
|
||||
*
|
||||
* WordPress deletes a user without knowing anything about lessons, so a student
|
||||
* removed from **Users → Delete** used to leave their bookings behind: the
|
||||
* availability slots stayed marked booked and unbookable by anyone else, the
|
||||
* lessons stayed on the instructor's schedule under a name that no longer
|
||||
* resolved, and a group class kept a seat filled by nobody.
|
||||
*
|
||||
* So each upcoming booking is cancelled the same way a real cancellation is —
|
||||
* marked cancelled, its slot released, its still-pending payment voided. Past
|
||||
* lessons are deliberately left alone: they happened, they may have been paid
|
||||
* for, and the payment report has to keep adding up.
|
||||
*
|
||||
* A **guardian** takes their children with them. A child account is login-less
|
||||
* and exists only so the guardian has somebody to book for; without the
|
||||
* guardian nobody can reach it, book for it, or be billed for it, so leaving it
|
||||
* behind leaves an unreachable student on the roster holding slots that will
|
||||
* never be used. Each child's bookings are released on the same terms, the link
|
||||
* row goes, and the account is deleted.
|
||||
*
|
||||
* No account credit is issued for a paid lesson, unlike a cancellation the
|
||||
* student asks for. A credit only has value against future billing on the
|
||||
* account it belongs to, and that account is being deleted; a refund owed to
|
||||
* someone who has left is a decision for the studio to make and record, not one
|
||||
* to silently write into a table nobody will read again.
|
||||
*/
|
||||
class DeletedUserCleanup {
|
||||
|
||||
/**
|
||||
* Accounts already dealt with this request, so deleting a guardian's child
|
||||
* — which fires `delete_user` again and re-enters this very handler — cannot
|
||||
* loop or redo work. It also makes a self-referential or circular guardian
|
||||
* link, however it got into the table, terminate rather than recurse.
|
||||
*
|
||||
* @var array<int, true>
|
||||
*/
|
||||
private array $handled = [];
|
||||
|
||||
public function __construct(
|
||||
private BookingRepository $bookings,
|
||||
private AvailabilityRepository $availability,
|
||||
private EnrollmentRepository $enrollments,
|
||||
private PaymentService $payments,
|
||||
private GuardianRepository $links,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
// `delete_user` fires before the row goes, which is what lets the lookups
|
||||
// below still find the account's bookings and children. `wpmu_delete_user`
|
||||
// is the multisite equivalent for a user removed from the network entirely.
|
||||
add_action( 'delete_user', [ $this, 'releaseBookings' ] );
|
||||
add_action( 'wpmu_delete_user', [ $this, 'releaseBookings' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Release everything the account had booked ahead of it, then remove any
|
||||
* children that only existed to be booked for.
|
||||
*/
|
||||
public function releaseBookings( int $userId ): void {
|
||||
if ( $userId <= 0 || isset( $this->handled[ $userId ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handled[ $userId ] = true;
|
||||
|
||||
$this->release( $userId );
|
||||
$this->removeChildren( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one account's upcoming lessons and active enrolments, freeing the
|
||||
* slot and voiding the pending payment behind each.
|
||||
*/
|
||||
private function release( int $studentId ): void {
|
||||
// Upcoming and not already cancelled — the only bookings that are still
|
||||
// holding anything.
|
||||
foreach ( $this->bookings->findUpcomingForStudent( $studentId ) as $lesson ) {
|
||||
$this->bookings->updateStatus( (int) $lesson->id, Lesson::STATUS_CANCELLED );
|
||||
$this->availability->release( $lesson->slotId );
|
||||
$this->payments->voidPending( $lesson->paymentId );
|
||||
}
|
||||
|
||||
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
|
||||
if ( Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->enrollments->updateStatus( (int) $enrollment->id, Enrollment::STATUS_CANCELLED );
|
||||
$this->payments->voidPending( $enrollment->paymentId );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every child linked to a departing guardian, releasing what each was
|
||||
* holding first. Each child is marked handled *before* it is deleted, so the
|
||||
* `delete_user` this fires re-enters and returns without redoing the release.
|
||||
*/
|
||||
private function removeChildren( int $guardianId ): void {
|
||||
foreach ( $this->links->findByGuardian( $guardianId ) as $link ) {
|
||||
$childId = $link->studentId;
|
||||
|
||||
if ( $childId <= 0 || $childId === $guardianId || isset( $this->handled[ $childId ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->handled[ $childId ] = true;
|
||||
|
||||
$this->release( $childId );
|
||||
$this->links->delete( $guardianId, $childId );
|
||||
$this->guardians->deleteUser( $childId );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,12 @@ class LoginPage {
|
||||
'remember' => isset( $_POST['rememberme'] ),
|
||||
];
|
||||
|
||||
$user = wp_signon( $credentials, false );
|
||||
// The secure-cookie argument is deliberately left at its default. Only
|
||||
// the empty string makes wp_signon() work it out from is_ssl(); passing
|
||||
// an explicit false skips that and issues the plain, non-Secure auth
|
||||
// cookie on an HTTPS site — a session that then leaks over the first
|
||||
// http:// request to the domain.
|
||||
$user = wp_signon( $credentials );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* What counts as an acceptable signup password.
|
||||
*
|
||||
* The check is deliberately split across the two sides, because the two sides
|
||||
* can do different things:
|
||||
*
|
||||
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
|
||||
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test —
|
||||
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple` —
|
||||
* but it is only advice, because anything in a browser can be turned off.
|
||||
* - **This class** runs on the server and is the rule that actually holds. It
|
||||
* cannot score a password the way zxcvbn does without shipping a dictionary,
|
||||
* so it does not pretend to: it rejects the categorically bad — too short,
|
||||
* the user's own name or email, a password from the well-known lists, or one
|
||||
* built from almost no distinct characters.
|
||||
*
|
||||
* Neither half is sufficient alone, which is the point. A password that clears
|
||||
* both is not guaranteed strong; one that fails either is definitely not.
|
||||
*/
|
||||
class PasswordPolicy {
|
||||
|
||||
/**
|
||||
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
|
||||
* against composition rules ("must contain a symbol") on the grounds that they
|
||||
* push people towards predictable substitutions. Length plus the checks below
|
||||
* does more for less annoyance.
|
||||
*/
|
||||
public const MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The zxcvbn score the browser demands before it will let the form submit,
|
||||
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
|
||||
* passwords a stranger would guess while still accepting an ordinary
|
||||
* memorable one — a studio signup form is not a bank.
|
||||
*/
|
||||
public const MIN_SCORE = 2;
|
||||
|
||||
/**
|
||||
* How much of the user's own identity has to appear in the password before it
|
||||
* is refused. Short enough to catch a name inside a longer password, long
|
||||
* enough that a two- or three-letter coincidence does not trip it.
|
||||
*/
|
||||
private const IDENTITY_FRAGMENT_LENGTH = 4;
|
||||
|
||||
/** Fewest distinct characters a password may be built from. */
|
||||
private const MIN_DISTINCT_CHARACTERS = 4;
|
||||
|
||||
/**
|
||||
* Why this password is unacceptable, or null when it passes.
|
||||
*
|
||||
* `$email` and `$displayName` are what the same submission is claiming as an
|
||||
* identity, so they can be checked against the password before either exists
|
||||
* as a user.
|
||||
*/
|
||||
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
|
||||
// Not trimmed: a leading or trailing space is a legitimate character, and
|
||||
// silently changing what someone typed would lock them out later.
|
||||
if ( strlen( $password ) < self::MIN_LENGTH ) {
|
||||
return sprintf(
|
||||
/* translators: %d: minimum number of characters. */
|
||||
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
|
||||
self::MIN_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
$lower = strtolower( $password );
|
||||
|
||||
if ( in_array( $lower, self::commonPasswords(), true ) ) {
|
||||
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
|
||||
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
|
||||
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the password contains the user's display name, their email address,
|
||||
* or the part of it before the `@` — the first things anyone guessing would
|
||||
* try, and the reason "grace2019" is worse than its length suggests.
|
||||
*/
|
||||
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
|
||||
$email = strtolower( trim( $email ) );
|
||||
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
|
||||
|
||||
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
|
||||
|
||||
foreach ( $fragments as $fragment ) {
|
||||
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwords common enough that a guess costs nothing. Only entries at least
|
||||
* {@see MIN_LENGTH} long are worth listing — anything shorter is already
|
||||
* refused — so this is the long tail of the usual leaked-password lists
|
||||
* rather than the whole of it. zxcvbn in the browser covers the rest.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function commonPasswords(): array {
|
||||
return [
|
||||
'password',
|
||||
'password1',
|
||||
'password12',
|
||||
'password123',
|
||||
'passw0rd',
|
||||
'p@ssword',
|
||||
'p@ssw0rd',
|
||||
'12345678',
|
||||
'123456789',
|
||||
'1234567890',
|
||||
'123123123',
|
||||
'qwertyui',
|
||||
'qwertyuiop',
|
||||
'qwerty123',
|
||||
'qwerty12',
|
||||
'1qaz2wsx',
|
||||
'zaq12wsx',
|
||||
'iloveyou',
|
||||
'princess',
|
||||
'sunshine',
|
||||
'football',
|
||||
'baseball',
|
||||
'basketball',
|
||||
'superman',
|
||||
'batman123',
|
||||
'trustno1',
|
||||
'welcome1',
|
||||
'welcome123',
|
||||
'letmein1',
|
||||
'letmein123',
|
||||
'admin123',
|
||||
'administrator',
|
||||
'abc12345',
|
||||
'abcd1234',
|
||||
'monkey123',
|
||||
'dragon123',
|
||||
'michael1',
|
||||
'jennifer',
|
||||
'starwars',
|
||||
'computer',
|
||||
'whatever',
|
||||
'freedom1',
|
||||
'changeme',
|
||||
'secret123',
|
||||
'login123',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,59 @@ namespace Unsupervised\Schedular\Auth;
|
||||
*
|
||||
* Both checks key solely off the pending user meta, so invite- and
|
||||
* admin-created students (which carry none of it) are unaffected.
|
||||
*
|
||||
* It also decides which new accounts land in that pending state to begin with —
|
||||
* see {@see holdUnknownSignup()}, which closes the gap left by open registration
|
||||
* turning the site's own `users_can_register` on.
|
||||
*/
|
||||
class RegistrationLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
|
||||
add_action( 'user_register', [ $this, 'holdUnknownSignup' ], 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold any student account created by an unauthenticated request that did not
|
||||
* come through the studio's own signup form.
|
||||
*
|
||||
* Enabling open registration switches the site's `users_can_register` on and
|
||||
* makes Student the default role for a new user, because that is what the
|
||||
* studio's registration page needs. But those are *site-wide* settings: they
|
||||
* also arm every other route into `wp_insert_user()` the site happens to have
|
||||
* — another plugin's signup form, a membership add-on — and an account minted
|
||||
* that way arrives holding `book_lesson`, with no email confirmed, no studio
|
||||
* approval, and no policy acceptance on file. It could book and be billed
|
||||
* immediately.
|
||||
*
|
||||
* So the state is decided here, at the one point every path passes through,
|
||||
* rather than trusted to whichever form happened to create the account:
|
||||
*
|
||||
* - **Not a student** — instructors and everyone else are none of this
|
||||
* feature's business.
|
||||
* - **Created by staff** (anyone holding `manage_students`, which includes an
|
||||
* administrator adding a user from wp-admin) — a deliberate act by someone
|
||||
* who could have approved them anyway; approving their own creation is
|
||||
* ceremony, so the account is left active.
|
||||
* - **Anything else** — held, and queued for review under **Pending
|
||||
* Students**.
|
||||
*
|
||||
* The studio's own paths land in the last case and then say what they meant:
|
||||
* a self-signup calls {@see RegistrationStatus::markPending()} (which replaces
|
||||
* the hold with a real, unconfirmed pending state), and an invited student and
|
||||
* a guardian's child are approved outright by the code that creates them.
|
||||
*/
|
||||
public function holdUnknownSignup( int $userId ): void {
|
||||
if ( $userId <= 0 || ! RoleManager::isStudent( $userId ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
RegistrationStatus::hold( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+489
-41
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
@@ -18,6 +19,15 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class RegistrationPage {
|
||||
|
||||
/** "Who are you registering?": the account holder, and nobody else. */
|
||||
public const FOR_SELF = 'self';
|
||||
|
||||
/** Only other people — the account holder is not a student. */
|
||||
public const FOR_STUDENTS = 'students';
|
||||
|
||||
/** The account holder *and* other people. */
|
||||
public const FOR_BOTH = 'both';
|
||||
|
||||
/** Success signal: an invited student was created and logged in. */
|
||||
private const RESULT_INVITE = 'invite';
|
||||
|
||||
@@ -30,6 +40,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,
|
||||
@@ -40,20 +57,43 @@ class RegistrationPage {
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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() ) {
|
||||
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
||||
// Both logged-in outcomes are dead ends without somewhere to go next,
|
||||
// so both offer the same "continue" link to the configured page.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
$link = $this->continueLink( $atts );
|
||||
|
||||
if ( self::RESULT_INVITE === $registered ) {
|
||||
// An invited student is done the moment they land here logged in.
|
||||
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>' . $link . '</div>';
|
||||
}
|
||||
|
||||
return '<div class="us-register-form"><p>'
|
||||
. esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' )
|
||||
. '</p>' . $link . '</div>';
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -65,32 +105,57 @@ 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.
|
||||
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
||||
|
||||
// Where the post-confirmation prompt sends students to sign in.
|
||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
||||
$loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$canRegister = $open || $inviteValid;
|
||||
$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 ) {
|
||||
// The signup form carries the same policy-acceptance markup as the booking
|
||||
// gate, so it needs the plugin stylesheet that formats it.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
// The script drives the parent/guardian section (revealing it, taking the
|
||||
// account holder's own panel out of play, and cloning the child block for
|
||||
// "add another") and the password meter, so it is needed whenever the form
|
||||
// itself is on screen.
|
||||
if ( $canRegister && '' === $successType ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
|
||||
// The browser gate reads the same numbers the server enforces, so the
|
||||
// two cannot drift into disagreeing about what it accepted.
|
||||
wp_localize_script(
|
||||
'us-scheduler-register',
|
||||
'usSchedulerPassword',
|
||||
[
|
||||
'minLength' => PasswordPolicy::MIN_LENGTH,
|
||||
'minScore' => PasswordPolicy::MIN_SCORE,
|
||||
'strings' => [
|
||||
'short' => sprintf(
|
||||
/* translators: %d: minimum number of characters. */
|
||||
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
|
||||
PasswordPolicy::MIN_LENGTH
|
||||
),
|
||||
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
|
||||
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
|
||||
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
|
||||
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
@@ -98,6 +163,77 @@ class RegistrationPage {
|
||||
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
|
||||
@@ -144,10 +280,6 @@ class RegistrationPage {
|
||||
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
||||
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
||||
|
||||
if ( strlen( $password ) < 8 ) {
|
||||
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// The email is fixed by a personal invite; group-link signups and
|
||||
// self-signups supply their own.
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
@@ -159,6 +291,15 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// After the email, so the password can be checked against it. The browser
|
||||
// scores the password with zxcvbn and refuses to submit a weak one, but
|
||||
// that is advice a client can decline to take — this is the check that
|
||||
// holds. See PasswordPolicy for why the two halves differ.
|
||||
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
|
||||
if ( null !== $passwordError ) {
|
||||
return esc_html( $passwordError );
|
||||
}
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
||||
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
||||
@@ -170,27 +311,94 @@ 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' );
|
||||
// The account-signup questions describe a *student* — instrument, level,
|
||||
// school — not whoever holds the account. So they are asked of each
|
||||
// student being added, and of the account holder only when they are a
|
||||
// student themselves. "Both" is both.
|
||||
$registeringFor = $this->submittedRegisteringFor();
|
||||
|
||||
// A "students only" question is never put to the account holder, so it is
|
||||
// dropped before their answers are validated or stored — a crafted post
|
||||
// cannot file one against them.
|
||||
$selfQuestions = array_values(
|
||||
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
|
||||
);
|
||||
|
||||
// "Students" and "both" collect student blocks; only "self" does not.
|
||||
$isGuardian = self::FOR_SELF !== $registeringFor;
|
||||
|
||||
// "Self" and "both" make the account holder a student, so they answer the
|
||||
// questions in their own right. Only a pure guardian does not.
|
||||
$asksSelf = self::FOR_STUDENTS !== $registeringFor;
|
||||
|
||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||
$answers = $asksSelf ? $this->submittedAnswers() : [];
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by the caller.
|
||||
$birthYear = $asksSelf ? trim( sanitize_text_field( Val::string( wp_unslash( $_POST['birth_year'] ?? '' ) ) ) ) : '';
|
||||
|
||||
// Everything is validated before a single user is created, so a bad child
|
||||
// block never leaves a half-registered family behind.
|
||||
if ( $isGuardian && [] === $children ) {
|
||||
return esc_html__( 'Please add at least one student, or choose "Just myself" instead.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// Name and birth year are required per student, and are checked here for
|
||||
// the same reason the questions below are: the child blocks are hidden
|
||||
// until the guardian box is ticked, so the browser cannot be asked to
|
||||
// enforce them without blocking a signup that has no children at all.
|
||||
foreach ( $children as $child ) {
|
||||
if ( '' === $child['name'] ) {
|
||||
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
|
||||
return esc_html( GuardianService::birthYearError() );
|
||||
}
|
||||
}
|
||||
|
||||
// Checked as two passes rather than one so the message can say *whose*
|
||||
// answers are missing — under "both" a single message could not.
|
||||
foreach ( array_column( $children, 'answers' ) as $set ) {
|
||||
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
// The account holder is a student too under "self" and "both", so the same
|
||||
// birth year every other student gives is asked of them — and checked
|
||||
// here rather than left to the browser, for the same reason as the
|
||||
// children's: the panel is hidden for a pure guardian, so `required`
|
||||
// alone cannot be trusted to have applied.
|
||||
if ( $asksSelf && 0 === GuardianService::normaliseBirthYear( $birthYear ) ) {
|
||||
return esc_html( GuardianService::ownBirthYearError() );
|
||||
}
|
||||
|
||||
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
|
||||
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' );
|
||||
}
|
||||
|
||||
// Nickname as well as display name. WordPress defaults nickname to
|
||||
// `user_login`, which here is the email address — so without this the
|
||||
// account's own address became its nickname, and every screen that names
|
||||
// a person through `UserName` showed the address instead of the name they
|
||||
// had just typed. `UserName` copes with the accounts already created that
|
||||
// way; this stops any more of them.
|
||||
$name = '' !== $displayName ? $displayName : $email;
|
||||
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => $password,
|
||||
'display_name' => '' !== $displayName ? $displayName : $email,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
||||
]
|
||||
);
|
||||
@@ -199,10 +407,37 @@ class RegistrationPage {
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
||||
|
||||
// Only "students" means the account holder is not a student themselves;
|
||||
// "both" registers them alongside the people they book for.
|
||||
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
|
||||
|
||||
if ( $asksSelf ) {
|
||||
$this->guardians->setBirthYear( (int) $userId, $birthYear );
|
||||
}
|
||||
|
||||
if ( $isGuardian ) {
|
||||
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
||||
if ( '' !== $failure ) {
|
||||
return $failure;
|
||||
}
|
||||
}
|
||||
|
||||
// After the children, so a rollback that deletes this account cannot
|
||||
// leave its answers behind pointing at a user that no longer exists.
|
||||
if ( $asksSelf ) {
|
||||
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
|
||||
}
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
// An invited student is pre-approved by the invitation itself — the
|
||||
// studio picked the address and sent the link. Clears the hold the
|
||||
// registration gate puts on every student account created by an
|
||||
// unauthenticated request ({@see RegistrationLoginGate::holdUnknownSignup()}),
|
||||
// which would otherwise leave them signed in but unable to book.
|
||||
RegistrationStatus::approve( (int) $userId );
|
||||
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
// A personal invite may carry a group-class grant (invited by email);
|
||||
@@ -231,21 +466,102 @@ class RegistrationPage {
|
||||
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page id chosen for the post-registration destination, from either the
|
||||
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts
|
||||
*/
|
||||
private function successPageId( array $atts ): int {
|
||||
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* URL the post-confirmation sign-in link points to: the chosen login page
|
||||
* when one is configured (and still exists), otherwise the WordPress login
|
||||
* screen.
|
||||
*/
|
||||
private function loginUrl( int $loginPageId ): string {
|
||||
if ( $loginPageId > 0 ) {
|
||||
$url = get_permalink( $loginPageId );
|
||||
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
||||
}
|
||||
|
||||
if ( is_string( $url ) ) {
|
||||
return $url;
|
||||
}
|
||||
/**
|
||||
* The "continue" paragraph shown to a logged-in visitor, or an empty string
|
||||
* when no destination page is configured. The link names the chosen page, so
|
||||
* the visitor knows where it goes before clicking; an untitled page falls
|
||||
* back to generic wording rather than reading "Continue to ".
|
||||
*
|
||||
* The sign-in-page fallback {@see loginUrl()} applies is deliberately not
|
||||
* used here: pointing someone who is already signed in at the login screen is
|
||||
* the same dead end with extra steps, so no link is better than that one.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts
|
||||
*/
|
||||
private function continueLink( array $atts ): string {
|
||||
$pageId = $this->successPageId( $atts );
|
||||
$continue = $this->continueUrl( $pageId );
|
||||
|
||||
if ( null === $continue ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return wp_login_url();
|
||||
$title = trim( Val::string( get_the_title( $pageId ) ) );
|
||||
$label = '' === $title
|
||||
? esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
||||
: esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: title of the page the student continues to. */
|
||||
__( 'Continue to %s', 'unsupervised-schedular' ),
|
||||
$title
|
||||
)
|
||||
);
|
||||
|
||||
return '<p><a href="' . esc_url( $continue ) . '">' . $label . '</a></p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen post-registration page's URL, or null when none is configured
|
||||
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
||||
* WordPress-login-screen fallback, so callers that need a page the student
|
||||
* was actually sent to — the invited-student link and the block's
|
||||
* auto-redirect — can tell "not configured" from "configured".
|
||||
*/
|
||||
public function continueUrl( int $pageId ): ?string {
|
||||
if ( $pageId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = get_permalink( $pageId );
|
||||
|
||||
return is_string( $url ) ? $url : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request is a *finished* registration — the states the
|
||||
* block's auto-redirect may act on:
|
||||
*
|
||||
* - an invited student who just signed up and is now logged in, and
|
||||
* - a self-signup returning from the emailed confirmation link, whether
|
||||
* their account is ready (`ready`) or awaiting studio approval (`1`).
|
||||
*
|
||||
* Deliberately excluded: the intermediate "check your email" step (the
|
||||
* student would never see the instruction) and every failure — a validation
|
||||
* error or an expired confirmation link (`expired`) — so the message always
|
||||
* gets shown. The `us_confirmed` values are set by
|
||||
* {@see EmailConfirmationHandler::maybeConfirm()}.
|
||||
*/
|
||||
public function isRegistrationComplete(): bool {
|
||||
// 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 ( self::RESULT_INVITE === $registered ) {
|
||||
return is_user_logged_in();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
|
||||
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
||||
|
||||
return in_array( $confirmed, [ '1', 'ready' ], true );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,6 +575,131 @@ class RegistrationPage {
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any required question in `$questions` is left blank in `$answers`.
|
||||
*
|
||||
* `$forChild` picks which required-ness applies: a question can be optional
|
||||
* for the account holder answering about themselves and still required of
|
||||
* every student they register.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers
|
||||
*/
|
||||
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
|
||||
foreach ( $questions as $question ) {
|
||||
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
|
||||
|
||||
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
|
||||
* {@see FOR_BOTH}.
|
||||
*
|
||||
* Anything unrecognised — including a form posted without the field at all —
|
||||
* falls back to "just myself", the choice that collects the least and grants
|
||||
* the least. A missing radio must not be read as "register these children".
|
||||
*/
|
||||
private function submittedRegisteringFor(): string {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
|
||||
|
||||
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child blocks submitted with a guardian signup, as
|
||||
* `children[<n>][name|birth_year|answers]`.
|
||||
*
|
||||
* An **entirely empty** block is dropped rather than rejected — the form always
|
||||
* renders one spare for "add another", and an untouched spare is not a mistake
|
||||
* the guardian needs telling about. A block with anything at all filled in is
|
||||
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
|
||||
* year rather than silently discarding what they typed.
|
||||
*
|
||||
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
|
||||
*/
|
||||
private function submittedChildren(): array {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each field is unslashed and sanitized below.
|
||||
$raw = $_POST['children'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $child ) {
|
||||
if ( ! is_array( $child ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
|
||||
$birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
|
||||
|
||||
$answers = [];
|
||||
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'birth_year' => $birthYear,
|
||||
'answers' => $answers,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create each child of a guardian signup: the login-less account, its answers
|
||||
* to the per-child questions, and a signup-policy acceptance recorded against
|
||||
* the child but attributed to the guardian who agreed for them.
|
||||
*
|
||||
* Returns an empty string on success, or an error message after rolling the
|
||||
* whole family back — every child created so far *and* the guardian. A signup
|
||||
* that half-worked would leave the guardian with an account they cannot
|
||||
* re-register and children they never confirmed, so it is undone entirely and
|
||||
* they simply try again.
|
||||
*
|
||||
* @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
|
||||
* @param list<Question> $questions
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function createChildren( array $children, array $questions, array $policyForms, int $guardianId ): string {
|
||||
$created = [];
|
||||
|
||||
foreach ( $children as $child ) {
|
||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
|
||||
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
foreach ( $created as $id ) {
|
||||
$this->guardians->deleteUser( $id );
|
||||
}
|
||||
$this->guardians->deleteUser( $guardianId );
|
||||
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$created[] = $childId;
|
||||
|
||||
$this->recordAnswers( $questions, $child['answers'], $childId );
|
||||
$this->recordAcceptances( $policyForms, $childId, $guardianId );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
@@ -308,9 +749,15 @@ class RegistrationPage {
|
||||
/**
|
||||
* Record account-time acceptances for each signup policy version.
|
||||
*
|
||||
* `$userId` is who the policy binds — the guardian for their own acceptance,
|
||||
* or the child for one accepted on their behalf — and `$acceptedBy` is who
|
||||
* actually ticked the box. Recording both is what makes the row legally
|
||||
* meaningful: "guardian X agreed to version N for child Y, at this time, from
|
||||
* this IP".
|
||||
*
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function recordAcceptances( array $policyForms, int $userId ): void {
|
||||
private function recordAcceptances( array $policyForms, int $userId, int $acceptedBy ): void {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
@@ -321,6 +768,7 @@ class RegistrationPage {
|
||||
studentId: $userId,
|
||||
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
||||
registrationId: $userId,
|
||||
acceptedBy: $acceptedBy,
|
||||
ipAddress: '' !== $ip ? $ip : null,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -58,6 +58,11 @@ class RegistrationStatus {
|
||||
$rawToken = wp_generate_password( 32, false );
|
||||
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
// Explicitly *un*confirmed. The account may already have been held by
|
||||
// {@see hold()} on `user_register` — which counts the email as confirmed,
|
||||
// having never asked for confirmation — and this signup did ask, so the
|
||||
// answer has to be waited for rather than inherited.
|
||||
delete_user_meta( $userId, self::META_EMAIL_CONFIRMED );
|
||||
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
|
||||
update_user_meta(
|
||||
$userId,
|
||||
@@ -72,6 +77,21 @@ class RegistrationStatus {
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a student account that appeared without going through the studio's own
|
||||
* signup form — see {@see RegistrationLoginGate::holdUnknownSignup()}.
|
||||
*
|
||||
* The email counts as confirmed, because nobody ever asked for confirmation
|
||||
* and there is no token to answer with: blocking the login outright would
|
||||
* strand the account with no way forward. What the hold actually withholds is
|
||||
* the booking capability, until a studio admin approves them from **Pending
|
||||
* Students** — the same queue, and the same decision, as a self-signup.
|
||||
*/
|
||||
public static function hold( int $userId ): void {
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the account's email confirmed and discard the (now spent) token. The
|
||||
* account stays awaiting approval.
|
||||
|
||||
@@ -60,6 +60,27 @@ class RoleManager {
|
||||
self::CAP_EXPORT_PAYMENTS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a user account is a student the studio may act for.
|
||||
*
|
||||
* Deliberately the role and not the `book_lesson` capability: that capability
|
||||
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
|
||||
* and from a self-signup still awaiting approval
|
||||
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
|
||||
* can book or enrol *in their own name*. Staff booking or enrolling on their
|
||||
* behalf is the case those restrictions exist to leave open — and for a child,
|
||||
* whose account is never signed in to, it is the only route there is.
|
||||
*
|
||||
* Use this for every "may the studio register this person?" check, so the
|
||||
* pickers staff choose from and the guards that vet their choice cannot drift
|
||||
* into offering someone who is then refused.
|
||||
*/
|
||||
public static function isStudent( int $userId ): bool {
|
||||
$user = $userId > 0 ? get_userdata( $userId ) : false;
|
||||
|
||||
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
|
||||
}
|
||||
|
||||
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
|
||||
|
||||
public function register(): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -23,6 +25,8 @@ class StudentController {
|
||||
private BillingMethodResolver $resolver,
|
||||
private StudentHistory $history,
|
||||
private StudentActions $actions,
|
||||
private GuardianService $guardians,
|
||||
private SessionSchedule $sessions,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -43,10 +47,14 @@ class StudentController {
|
||||
fn( \WP_User $user ): array => [
|
||||
'id' => (int) $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
// A child's own address is an undeliverable placeholder, so the
|
||||
// list shows the guardian's — the address an admin would use.
|
||||
'email' => $this->guardians->contactFor( (int) $user->ID )['email'],
|
||||
'registered' => $user->user_registered,
|
||||
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
||||
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
|
||||
'guardian' => $this->guardians->guardianOf( (int) $user->ID ),
|
||||
'children' => $this->guardians->children( (int) $user->ID ),
|
||||
],
|
||||
array_filter(
|
||||
get_users(
|
||||
@@ -128,7 +136,15 @@ class StudentController {
|
||||
$this->bookings->findByStudent( (int) $student->ID )
|
||||
);
|
||||
|
||||
$schedule = StudentSchedule::partition( $rows, $now );
|
||||
// Group classes join the upcoming table so "what is this student booked
|
||||
// into next week?" has one answer instead of two. Only their upcoming
|
||||
// sessions are added: the enrolment table below already records the whole
|
||||
// history, and a term's worth of past dates would bury the lessons under
|
||||
// "Past lessons".
|
||||
$schedule = StudentSchedule::partition(
|
||||
array_merge( $rows, $this->groupSessionRows( (int) $student->ID, $now ) ),
|
||||
$now
|
||||
);
|
||||
$upcoming = $schedule['upcoming'];
|
||||
$past = $schedule['past'];
|
||||
|
||||
@@ -149,11 +165,33 @@ class StudentController {
|
||||
$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 ) : [];
|
||||
$creditCurrency = $this->creditCurrency( $credits );
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
// The family panel, and the account whose balance actually settles this
|
||||
// student's charges — a child's is their guardian's, so showing the
|
||||
// child's own (always empty) balance would be actively misleading.
|
||||
$guardian = $this->guardians->guardianOf( (int) $student->ID );
|
||||
$children = $this->guardians->children( (int) $student->ID );
|
||||
$payer = $this->guardians->contactFor( (int) $student->ID );
|
||||
|
||||
$creditBalance = $canBilling ? $this->history->creditBalance( $payer['id'] ) : 0.0;
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
$pageSlug = '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).
|
||||
*
|
||||
@@ -166,6 +204,8 @@ class StudentController {
|
||||
|
||||
return [
|
||||
'id' => (int) $lesson->id,
|
||||
'kind' => 'lesson',
|
||||
'schedule' => null,
|
||||
'start_dt' => $slot ? $slot->startDt : '',
|
||||
'end_dt' => $slot ? $slot->endDt : '',
|
||||
'offering' => $offering ? $offering->title : '—',
|
||||
@@ -173,4 +213,34 @@ class StudentController {
|
||||
'status' => $lesson->status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The student's upcoming group-class sessions, shaped like the lesson rows
|
||||
* they sit beside. `kind` is what keeps the table honest: a session is a date
|
||||
* in a term, not a booked slot, so the row offers no "Cancel" — withdrawing
|
||||
* is done from the enrolment table, which removes the whole class at once.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function groupSessionRows( int $studentId, string $now ): array {
|
||||
return array_map(
|
||||
static function ( array $session ): array {
|
||||
$instructor = get_userdata( $session['instructor_id'] );
|
||||
|
||||
return [
|
||||
'id' => $session['enrollment_id'],
|
||||
'kind' => SessionSchedule::KIND,
|
||||
'start_dt' => $session['start_dt'],
|
||||
'end_dt' => $session['end_dt'],
|
||||
// Set when the class has no time to put on a clock; shown in the
|
||||
// When column in place of a date. See GroupClass\SessionSchedule.
|
||||
'schedule' => $session['schedule'],
|
||||
'offering' => $session['offering_title'],
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'],
|
||||
'status' => $session['status'],
|
||||
];
|
||||
},
|
||||
$this->sessions->upcomingForStudent( $studentId, $now )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -27,6 +29,7 @@ class StudentHistory {
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private PaymentRepository $payments,
|
||||
private CreditRepository $credits,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -129,6 +132,34 @@ class StudentHistory {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The student's total unused credit balance (from cancelled paid lessons),
|
||||
* applied automatically against future scheduled-billing charges.
|
||||
*/
|
||||
public function creditBalance( int $studentId ): float {
|
||||
return $this->credits->availableBalance( $studentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credit the student has been issued, newest first, with the amount, what
|
||||
* remains, and its state.
|
||||
*
|
||||
* @return list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}>
|
||||
*/
|
||||
public function credits( int $studentId ): array {
|
||||
return array_map(
|
||||
static fn( Credit $credit ): array => [
|
||||
'created_at' => $credit->createdAt ?? '',
|
||||
'amount' => $credit->amount,
|
||||
'remaining' => $credit->remaining,
|
||||
'currency' => $credit->currency,
|
||||
'reason' => $credit->reason ?? '—',
|
||||
'status' => $credit->status,
|
||||
],
|
||||
$this->credits->findByStudent( $studentId )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for a polymorphic registration target.
|
||||
*/
|
||||
|
||||
+32
-8
@@ -5,15 +5,25 @@ 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.
|
||||
* (first + last), then their nickname, then the display name — skipping any of
|
||||
* them that is really the account's login or email address, which is the thing
|
||||
* this class exists to keep off the screen.
|
||||
*/
|
||||
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.
|
||||
* The display name for a user: "First Last" when a real name is set, else the
|
||||
* first of nickname / display name that is an actual name. Falls back to the
|
||||
* numeric id (or an empty string when none is given) when the user cannot be
|
||||
* loaded or has nothing but identifiers on file.
|
||||
*
|
||||
* Display name is consulted at all because WordPress defaults **nickname** to
|
||||
* `user_login`, and signup uses the email address as the login — so a
|
||||
* self-registered account carries its own email as its nickname, and every
|
||||
* screen naming that person showed the address instead. The name they typed
|
||||
* was on file the whole time, in `display_name`. (Accounts created by a
|
||||
* guardian never hit this: `GuardianService::createChild()` sets `nickname`
|
||||
* outright, which is why children read correctly and their parents did not.)
|
||||
*/
|
||||
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
@@ -25,11 +35,25 @@ class UserName {
|
||||
return $full;
|
||||
}
|
||||
|
||||
$nickname = trim( $user->nickname );
|
||||
if ( '' !== $nickname ) {
|
||||
return $nickname;
|
||||
foreach ( [ $user->nickname, $user->display_name ] as $candidate ) {
|
||||
$candidate = trim( (string) $candidate );
|
||||
|
||||
if ( '' !== $candidate && ! self::isIdentifier( $candidate, $user ) ) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallbackId > 0 ? (string) $fallbackId : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a candidate name is really the account's login or email address
|
||||
* wearing a name's clothing — the case this class must never pass through.
|
||||
*/
|
||||
private static function isIdentifier( string $candidate, \WP_User $user ): bool {
|
||||
$candidate = strtolower( $candidate );
|
||||
|
||||
return strtolower( (string) $user->user_login ) === $candidate
|
||||
|| strtolower( (string) $user->user_email ) === $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class AvailabilityController {
|
||||
public function __construct(
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -21,9 +22,11 @@ class AvailabilityController {
|
||||
}
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
$notice = '';
|
||||
$error = '';
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
|
||||
$this->handleFormAction( $instructorId );
|
||||
[ $notice, $error ] = $this->handleFormAction( $instructorId );
|
||||
}
|
||||
|
||||
$slots = $this->repository->findByInstructor( $instructorId );
|
||||
@@ -44,72 +47,144 @@ class AvailabilityController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
||||
}
|
||||
|
||||
private function handleFormAction( int $instructorId ): void {
|
||||
/**
|
||||
* Run the submitted action and report what happened. Every branch returns a
|
||||
* message: a form that silently reloads leaves the instructor unable to tell
|
||||
* "saved 41 slots" from "saved nothing".
|
||||
*
|
||||
* @return array{string, string} Success notice and error message; each is
|
||||
* empty when it does not apply.
|
||||
*/
|
||||
private function handleFormAction( int $instructorId ): array {
|
||||
// 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->addSlot( $instructorId );
|
||||
return $this->addSlot( $instructorId );
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
|
||||
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId )
|
||||
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
|
||||
: [ '', __( 'That slot could not be deleted. It may already be booked, or belong to someone else.', 'unsupervised-schedular' ) ];
|
||||
}
|
||||
|
||||
if ( 'bulk_delete' === $action ) {
|
||||
// The array itself carries no data; each element is coerced and
|
||||
// absint-sanitized individually below.
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
$rawIds = $_POST['slot_ids'] ?? [];
|
||||
$rawIds = $_POST['slot_ids'] ?? [];
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
|
||||
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
|
||||
if ( $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ) ) {
|
||||
++$deleted;
|
||||
continue;
|
||||
}
|
||||
|
||||
++$failed;
|
||||
}
|
||||
|
||||
return $this->bulkDeleteResult( $deleted, $failed );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wording for a bulk delete, which can partly succeed.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function bulkDeleteResult( int $deleted, int $failed ): array {
|
||||
$notice = $deleted > 0
|
||||
? sprintf(
|
||||
/* translators: %d: number of availability slots deleted. */
|
||||
_n( '%d slot deleted.', '%d slots deleted.', $deleted, 'unsupervised-schedular' ),
|
||||
$deleted
|
||||
)
|
||||
: '';
|
||||
|
||||
$error = $failed > 0
|
||||
? sprintf(
|
||||
/* translators: %d: number of slots that could not be deleted. */
|
||||
_n(
|
||||
'%d slot could not be deleted — it may already be booked.',
|
||||
'%d slots could not be deleted — they may already be booked.',
|
||||
$failed,
|
||||
'unsupervised-schedular'
|
||||
),
|
||||
$failed
|
||||
)
|
||||
: '';
|
||||
|
||||
if ( 0 === $deleted && 0 === $failed ) {
|
||||
$error = __( 'No slots were selected.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
return [ $notice, $error ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a slot only when it exists and belongs to the given instructor.
|
||||
* The repository additionally refuses to delete booked slots.
|
||||
* The repository additionally refuses to delete booked slots. Returns whether
|
||||
* the row actually went away.
|
||||
*/
|
||||
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
|
||||
private function deleteOwnSlot( int $slotId, int $instructorId ): bool {
|
||||
if ( $slotId <= 0 ) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$slot = $this->repository->findById( $slotId );
|
||||
if ( $slot && $slot->instructorId === $instructorId ) {
|
||||
$this->repository->delete( $slotId );
|
||||
|
||||
if ( null === $slot || $slot->instructorId !== $instructorId ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->repository->delete( $slotId );
|
||||
}
|
||||
|
||||
private function addSlot( int $instructorId ): void {
|
||||
/**
|
||||
* Validate and persist a submitted window.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function addSlot( int $instructorId ): array {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
|
||||
|
||||
// A window must start and end on the same day (weekly repeat covers longer
|
||||
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
|
||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration > 0 ? $duration : 60,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
$window = $this->validator->validate(
|
||||
$instructorId,
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ),
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ),
|
||||
absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ),
|
||||
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
||||
);
|
||||
|
||||
if ( $window instanceof \WP_Error ) {
|
||||
return [ '', $window->get_error_message() ];
|
||||
}
|
||||
|
||||
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
|
||||
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
|
||||
|
||||
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
$ids = $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||
|
||||
// The window was valid, so it split into at least one slot — an empty
|
||||
// result means every insert failed.
|
||||
if ( [] === $ids ) {
|
||||
return [ '', __( 'The availability could not be saved. Please try again.', 'unsupervised-schedular' ) ];
|
||||
}
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: number of bookable slots created. */
|
||||
_n( 'Added %d bookable slot.', 'Added %d bookable slots.', count( $ids ), 'unsupervised-schedular' ),
|
||||
count( $ids )
|
||||
),
|
||||
'',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class AvailabilityEndpoint {
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -113,40 +112,18 @@ class AvailabilityEndpoint {
|
||||
}
|
||||
|
||||
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$instructorId = get_current_user_id();
|
||||
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
|
||||
|
||||
// A slot may only be tied to an offering the instructor owns, so it can
|
||||
// never inherit another instructor's price or payment routing at booking.
|
||||
if ( $offeringId > 0 ) {
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'That offering is not available.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
|
||||
|
||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
||||
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration > 0 ? $duration : 60,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
// Validation lives in WindowValidator so this endpoint and the admin form
|
||||
// enforce exactly the same rules.
|
||||
$window = $this->validator->validate(
|
||||
get_current_user_id(),
|
||||
Val::string( $request->get_param( 'start_dt' ) ),
|
||||
Val::string( $request->get_param( 'end_dt' ) ),
|
||||
absint( Val::int( $request->get_param( 'duration_minutes' ) ) ),
|
||||
absint( Val::int( $request->get_param( 'offering_id' ) ) ),
|
||||
);
|
||||
|
||||
if ( [] === $window->splitByDuration() ) {
|
||||
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
if ( $window instanceof \WP_Error ) {
|
||||
return $window;
|
||||
}
|
||||
|
||||
$ids = $this->repository->createFromWindow(
|
||||
@@ -155,6 +132,12 @@ class AvailabilityEndpoint {
|
||||
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
||||
);
|
||||
|
||||
// A valid window splits into at least one slot, so nothing written means
|
||||
// every insert failed.
|
||||
if ( [] === $ids ) {
|
||||
return new \WP_Error( 'not_saved', __( 'The availability could not be saved.', 'unsupervised-schedular' ), [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,14 @@ class AvailabilityRepository {
|
||||
$this->table = $db->prefix . 'us_availability';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert one slot row. Returns its id, or 0 when the write failed —
|
||||
* `insert_id` still holds the *previous* statement's id after a failed
|
||||
* insert, so returning it unconditionally made a failed write look like a
|
||||
* successful one.
|
||||
*/
|
||||
public function insert( AvailabilitySlot $slot ): int {
|
||||
$this->db->insert(
|
||||
$written = $this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'instructor_id' => $slot->instructorId,
|
||||
@@ -27,7 +33,7 @@ class AvailabilityRepository {
|
||||
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
return false === $written ? 0 : $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +48,17 @@ class AvailabilityRepository {
|
||||
$ids = [];
|
||||
|
||||
foreach ( $window->splitByDuration() as $slot ) {
|
||||
$ids = $weekly
|
||||
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
||||
: [ ...$ids, $this->insert( $slot ) ];
|
||||
if ( $weekly ) {
|
||||
$ids = array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $this->insert( $slot );
|
||||
|
||||
// A failed insert returns 0; it must not reach the caller as an id.
|
||||
if ( $id > 0 ) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
@@ -55,10 +69,14 @@ class AvailabilityRepository {
|
||||
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
||||
* first row).
|
||||
*
|
||||
* The count is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES`. The
|
||||
* form's `max` attribute says the same, but only this is binding — a
|
||||
* hand-crafted POST used to be able to ask for an unbounded number of rows.
|
||||
*
|
||||
* @return list<int> Inserted slot IDs.
|
||||
*/
|
||||
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
||||
$occurrences = max( 1, $occurrences );
|
||||
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $occurrences ) );
|
||||
$start = new \DateTimeImmutable( $first->startDt );
|
||||
$end = new \DateTimeImmutable( $first->endDt );
|
||||
|
||||
@@ -79,6 +97,13 @@ class AvailabilityRepository {
|
||||
)
|
||||
);
|
||||
|
||||
// A failed insert returns 0. Skipping it keeps a bogus id out of the
|
||||
// returned list and, more importantly, stops 0 becoming the series'
|
||||
// recurrence group — which would orphan every later occurrence.
|
||||
if ( $id <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === $groupId ) {
|
||||
$groupId = $id;
|
||||
$this->setRecurrenceGroup( $id, $groupId );
|
||||
|
||||
@@ -7,6 +7,23 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class AvailabilitySlot {
|
||||
|
||||
/** Lesson length used when none was submitted. */
|
||||
public const DEFAULT_DURATION_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* Lesson lengths a window can be split into, offered by the availability
|
||||
* form. The form hides the ones a given window is too short for.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
public const DURATION_CHOICES = [ 30, 60 ];
|
||||
|
||||
/**
|
||||
* Ceiling on a weekly series, matching the form's `max`. Enforced in the
|
||||
* repository too, so a hand-crafted POST cannot ask for ten thousand rows.
|
||||
*/
|
||||
public const MAX_WEEKLY_OCCURRENCES = 52;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $instructorId,
|
||||
public readonly string $startDt,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
|
||||
/**
|
||||
* Validates a submitted availability window.
|
||||
*
|
||||
* The admin form and the REST endpoint both accept the same window, and used to
|
||||
* check it independently — the endpoint returning a specific 400 for each
|
||||
* failure while the form simply returned, saving nothing and saying nothing. A
|
||||
* 30-minute window submitted with the default 60-minute lesson length was the
|
||||
* visible symptom: no rows, no error, no clue. Both callers now come through
|
||||
* here, so neither can drift from the other again.
|
||||
*
|
||||
* Every rejection is a `WP_Error` carrying a message written for the person who
|
||||
* submitted the form: the endpoint returns it as-is (the `status` data makes it
|
||||
* a 400), and the admin screen shows `get_error_message()` in a notice.
|
||||
*/
|
||||
class WindowValidator {
|
||||
|
||||
public function __construct( private OfferingRepository $offerings ) {}
|
||||
|
||||
/**
|
||||
* Check a submitted window and return it ready to persist.
|
||||
*
|
||||
* @param int $instructorId Instructor the window belongs to.
|
||||
* @param string $rawStart Submitted start, in any form {@see AvailabilitySlot::normalizeDateTime()} accepts.
|
||||
* @param string $rawEnd Submitted end, likewise.
|
||||
* @param int $durationMinutes Lesson length the window is split into; 0 falls back to the 60-minute default.
|
||||
* @param int $offeringId Offering the slots are tied to, or 0 for any private lesson.
|
||||
*
|
||||
* @return AvailabilitySlot|\WP_Error The window, or why it was rejected.
|
||||
*/
|
||||
public function validate( int $instructorId, string $rawStart, string $rawEnd, int $durationMinutes, int $offeringId ): AvailabilitySlot|\WP_Error {
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( $rawStart );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( $rawEnd );
|
||||
|
||||
if ( null === $startDt || null === $endDt ) {
|
||||
return new \WP_Error(
|
||||
'invalid_datetime',
|
||||
__( 'Enter a valid start and end date and time.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( $endDt <= $startDt ) {
|
||||
return new \WP_Error(
|
||||
'invalid_datetime',
|
||||
__( 'The end time must be after the start time.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_window',
|
||||
__( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
// A slot may only be tied to an offering the instructor owns, so it can
|
||||
// never inherit another instructor's price or payment routing at booking.
|
||||
if ( $offeringId > 0 ) {
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
||||
return new \WP_Error(
|
||||
'invalid_offering',
|
||||
__( 'That offering is not available.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$duration = $durationMinutes > 0 ? $durationMinutes : AvailabilitySlot::DEFAULT_DURATION_MINUTES;
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
);
|
||||
|
||||
// The window is stored as lesson-length slots, so one that cannot fit a
|
||||
// single lesson would persist nothing at all.
|
||||
if ( [] === $window->splitByDuration() ) {
|
||||
return new \WP_Error(
|
||||
'invalid_window',
|
||||
sprintf(
|
||||
/* translators: %d: the selected lesson length, in minutes. */
|
||||
__( 'This window is shorter than the %d-minute lesson length, so it holds no bookable slots. Choose a shorter lesson length or a longer window.', 'unsupervised-schedular' ),
|
||||
$duration
|
||||
),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
return $window;
|
||||
}
|
||||
}
|
||||
+145
-6
@@ -15,7 +15,29 @@ namespace Unsupervised\Schedular;
|
||||
*/
|
||||
class BlockPreview {
|
||||
|
||||
public static function booking(): string {
|
||||
/**
|
||||
* The marker a required field's label carries, matching the one
|
||||
* {@see Registration\QuestionField::render()} puts on a required question.
|
||||
*/
|
||||
private const REQUIRED_MARK = ' <span class="us-required" aria-hidden="true">*</span>';
|
||||
|
||||
/**
|
||||
* Sample booking page.
|
||||
*
|
||||
* @param string $mode Which halves the block embeds — one of
|
||||
* {@see Booking\BookingPage::MODE_BOTH},
|
||||
* `MODE_BOOKING` or `MODE_UPCOMING`. The preview shows
|
||||
* the same sections the published page would.
|
||||
*/
|
||||
public static function booking( string $mode = Booking\BookingPage::MODE_BOTH ): string {
|
||||
if ( Booking\BookingPage::MODE_UPCOMING === $mode ) {
|
||||
return sprintf(
|
||||
'<div id="us-booking-app">%s<div id="us-my-lessons">%s</div></div>',
|
||||
self::note( __( 'Editor preview — students see their own lessons on the published page.', 'unsupervised-schedular' ) ),
|
||||
self::upcomingLessons()
|
||||
);
|
||||
}
|
||||
|
||||
$days = [
|
||||
[
|
||||
'label' => __( 'Monday', 'unsupervised-schedular' ),
|
||||
@@ -51,20 +73,65 @@ class BlockPreview {
|
||||
);
|
||||
}
|
||||
|
||||
$lessons = Booking\BookingPage::MODE_BOOKING === $mode
|
||||
? ''
|
||||
: sprintf( '<div id="us-my-lessons">%s</div>', self::upcomingLessons() );
|
||||
|
||||
return sprintf(
|
||||
'<div id="us-booking-app">%s<div id="us-slot-list">%s</div></div>',
|
||||
'<div id="us-booking-app">%s%s<div id="us-slot-list">%s</div></div>',
|
||||
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
|
||||
$lessons,
|
||||
$dayHtml
|
||||
);
|
||||
}
|
||||
|
||||
public static function groupClasses(): string {
|
||||
/**
|
||||
* Sample "your upcoming lessons" panel, shared by the booking preview's
|
||||
* full and upcoming-only modes.
|
||||
*/
|
||||
private static function upcomingLessons(): string {
|
||||
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 class="us-my-lessons"><h3>%s</h3>'
|
||||
. '<div class="us-my-lesson"><div class="us-my-lesson-info">'
|
||||
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
|
||||
. '<span class="us-my-lesson-when">%s</span></div>'
|
||||
. '<div class="us-my-lesson-actions">'
|
||||
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
|
||||
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
|
||||
. '</div></div></div>',
|
||||
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Monday · 4:00 PM–4:30 PM', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Confirmed', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Cancel', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 class="us-class-when">%s</p>%s<p class="us-class-price">%s</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,
|
||||
// Prices on the live page always carry their cadence, so the sample does too.
|
||||
esc_html__( '25.00 CAD up front', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
@@ -108,6 +175,78 @@ class BlockPreview {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample family page: the account holder's own details, two representative
|
||||
* children and the add form, with the controls inert so the editor preview
|
||||
* cannot post.
|
||||
*/
|
||||
public static function family(): string {
|
||||
$self = sprintf(
|
||||
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
|
||||
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
|
||||
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
|
||||
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
|
||||
. '<p><button type="button" disabled>%s</button></p>',
|
||||
esc_html__( 'Your details', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Email', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Your name', 'unsupervised-schedular' ),
|
||||
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Save my details', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
$children = '';
|
||||
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
||||
$children .= sprintf(
|
||||
'<li class="us-family-child"><span class="us-family-child-name">%s</span>'
|
||||
. '<span class="us-family-child-actions"><a href="#">%s</a> <button type="button" disabled>%s</button></span></li>',
|
||||
esc_html( $name ),
|
||||
esc_html__( 'Edit', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Remove', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$add = sprintf(
|
||||
'<h4>%s</h4><p><label for="us-child-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-child-name"></p>'
|
||||
. '<p><label for="us-child-birth-year">%s' . self::REQUIRED_MARK . '</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
|
||||
. '<p><button type="button" disabled>%s</button></p>',
|
||||
esc_html__( 'Add a student', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Name', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Birth year', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Add student', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
|
||||
. '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
|
||||
esc_html__( 'Your profile', 'unsupervised-schedular' ),
|
||||
$self,
|
||||
esc_html__( 'Your students', 'unsupervised-schedular' ),
|
||||
$children,
|
||||
$add
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample account panel. Shown populated whatever the editor's own login
|
||||
* state, since on the published page a signed-out visitor may see nothing at
|
||||
* all and an empty box tells the person placing the block nothing.
|
||||
*/
|
||||
public static function account(): string {
|
||||
return sprintf(
|
||||
'<div class="us-account">%s'
|
||||
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
|
||||
. '<span class="us-account-email">%s</span></p>'
|
||||
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
|
||||
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
|
||||
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||
esc_html__( '[email protected]', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Sign out', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
private static function note( string $text ): string {
|
||||
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
||||
}
|
||||
|
||||
+118
-9
@@ -3,10 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
|
||||
/**
|
||||
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
|
||||
@@ -28,6 +30,8 @@ class BlockRegistrar {
|
||||
private LoginPage $loginPage,
|
||||
private RegistrationPage $registrationPage,
|
||||
private GroupClassPage $groupClassPage,
|
||||
private FamilyPage $familyPage,
|
||||
private AccountPage $accountPage,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -85,11 +89,23 @@ class BlockRegistrar {
|
||||
'us-scheduler/booking' => [
|
||||
'render' => [ $this, 'renderBooking' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
'autoRedirect' => $redirectToggle,
|
||||
'autoRedirect' => $redirectToggle,
|
||||
'lessonTypeId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
'showTypeFilter' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
],
|
||||
'displayMode' => [
|
||||
'type' => 'string',
|
||||
'default' => BookingPage::MODE_BOTH,
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/student-login' => [
|
||||
@@ -105,10 +121,15 @@ class BlockRegistrar {
|
||||
'us-scheduler/student-register' => [
|
||||
'render' => [ $this, 'renderRegistration' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
'autoRedirect' => $redirectToggle,
|
||||
'inviteOnlyMessage' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/group-classes' => [
|
||||
@@ -120,6 +141,24 @@ class BlockRegistrar {
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/family' => [
|
||||
'render' => [ $this, 'renderFamily' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/account' => [
|
||||
'render' => [ $this, 'renderAccount' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -129,7 +168,11 @@ class BlockRegistrar {
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderBooking( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
|
||||
if ( ! $this->isEditorPreview() ) {
|
||||
return $this->bookingPage->render( $attributes );
|
||||
}
|
||||
|
||||
return BlockPreview::booking( Val::string( $attributes['displayMode'] ?? BookingPage::MODE_BOTH ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,16 +199,39 @@ 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the account (who is signed in) block.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderAccount( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the family (manage-children) block.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderFamily( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::family() : $this->familyPage->render( $attributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side auto-redirect for blocks that opt in via their autoRedirect
|
||||
* attribute: logged-out visitors on a page containing the booking block
|
||||
* are sent to its login page, and logged-in visitors on a page containing
|
||||
* the student-login block are sent to its booking page. Hooked on
|
||||
* `template_redirect` because block rendering happens after output has
|
||||
* started, too late to send a Location header.
|
||||
* are sent to its login page, logged-in visitors on a page containing the
|
||||
* student-login block are sent to its booking page, and a student who has
|
||||
* just finished registering is sent to the register block's chosen page.
|
||||
* Hooked on `template_redirect` because block rendering happens after
|
||||
* output has started, too late to send a Location header.
|
||||
*/
|
||||
public function maybeAutoRedirect(): void {
|
||||
if ( is_admin() || ! is_singular() ) {
|
||||
@@ -177,6 +243,10 @@ class BlockRegistrar {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $this->maybeRedirectAfterRegistration( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( is_user_logged_in() ) {
|
||||
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
|
||||
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
||||
@@ -209,6 +279,45 @@ class BlockRegistrar {
|
||||
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a student whose registration has just completed to the register
|
||||
* block's chosen page, when the block opts in. Only the finished states
|
||||
* qualify (see {@see RegistrationPage::isRegistrationComplete()}): a
|
||||
* failure or the "check your email" step stays put so its message is read.
|
||||
* Unlike the other blocks there is no login-screen fallback — with no page
|
||||
* chosen there is nowhere to send them, so the link is shown instead.
|
||||
*
|
||||
* Returns whether the redirect was issued (it only ever returns in tests;
|
||||
* {@see redirect()} exits in production).
|
||||
*/
|
||||
private function maybeRedirectAfterRegistration( \WP_Post $post ): bool {
|
||||
// Checked before parsing the content because it is a couple of query
|
||||
// args, whereas every front-end request would otherwise pay for a
|
||||
// third block scan.
|
||||
if ( ! $this->registrationPage->isRegistrationComplete() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-register' );
|
||||
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pageId = Val::int( $attrs['loginPageId'] ?? 0 );
|
||||
if ( $pageId === $post->ID ) {
|
||||
return false; // Redirecting the page to itself would loop.
|
||||
}
|
||||
|
||||
$url = $this->registrationPage->continueUrl( $pageId );
|
||||
if ( null === $url ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->redirect( $url );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes of the first occurrence of the named block in the content,
|
||||
* searching inner blocks so blocks nested inside groups or columns are
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Booking a private lesson **for** a student, from wp-admin — the studio's
|
||||
* counterpart to the group class's "Add students directly". The front desk takes
|
||||
* a phone call, an instructor slots in a make-up lesson; neither of them can log
|
||||
* in as the student, and only a guardian may book through the student-facing
|
||||
* flow.
|
||||
*
|
||||
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
|
||||
* the same billing — and differs from a student's own booking in exactly four
|
||||
* ways, each deliberate:
|
||||
*
|
||||
* 1. **No intake questions or policy acceptances are recorded.** They are the
|
||||
* student's to answer and agree to; a staff member ticking boxes on their
|
||||
* behalf would be an audit trail that says something untrue. The lesson's
|
||||
* detail page simply shows none.
|
||||
* 2. **It is not bounded by what the student could book themselves.** Any open
|
||||
* slot of the instructor's, including one only reachable past a deadline.
|
||||
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
|
||||
* skips the payment entirely and confirms the lesson at once.
|
||||
* 4. **It can book for a student who cannot book at all** — a guardian's child,
|
||||
* or someone still awaiting approval. Both hold the student role but have
|
||||
* `book_lesson` withheld so that neither can book in their own name; that is
|
||||
* a limit on them, never on the studio acting for them.
|
||||
*/
|
||||
class AdminBooking {
|
||||
|
||||
/**
|
||||
* How far ahead the form's list of open times reaches. Long enough to book a
|
||||
* term ahead, short enough that the select stays a select.
|
||||
*/
|
||||
private const HORIZON_DAYS = 56;
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private LessonBooker $booker,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Book a lesson on a student's behalf, returning the notice to show. When
|
||||
* `$onlyInstructorId` is non-zero the slot must belong to that instructor —
|
||||
* how an instructor's own **My Lessons** page is kept to their own schedule,
|
||||
* where the studio **Scheduler** passes 0 and may book any instructor's time.
|
||||
*
|
||||
* @return string|\WP_Error Success notice, or why nothing was booked.
|
||||
*/
|
||||
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
|
||||
// The student role, not the `book_lesson` capability — see
|
||||
// {@see RoleManager::isStudent()} for why a child and an unapproved signup
|
||||
// must both be bookable for.
|
||||
if ( ! RoleManager::isStudent( $studentId ) ) {
|
||||
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$slot = $slotId > 0 ? $this->availability->findById( $slotId ) : null;
|
||||
|
||||
if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) {
|
||||
return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $slot->isBooked ) {
|
||||
return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$offering = $this->booker->resolveOffering( $slot, $offeringId );
|
||||
if ( $offering instanceof \WP_Error ) {
|
||||
return $offering;
|
||||
}
|
||||
|
||||
// A weekly reservation needs a weekly time to reserve. The student-facing
|
||||
// flow quietly books a single lesson when the slot does not repeat; here the
|
||||
// staff member asked for a term and must be told they are not getting one,
|
||||
// rather than discovering it later on the roster.
|
||||
$weekly = Lesson::RECURRENCE_WEEKLY === $recurrence;
|
||||
if ( $weekly && null === $slot->recurrenceGroup ) {
|
||||
return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$reservation = $this->booker->reserve(
|
||||
$slot,
|
||||
$offering,
|
||||
$studentId,
|
||||
$weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
|
||||
$notes,
|
||||
// Stamped on the lesson so it can be told apart later: only a lesson the
|
||||
// studio booked may have its intake recorded after the fact.
|
||||
get_current_user_id()
|
||||
);
|
||||
|
||||
if ( $reservation instanceof \WP_Error ) {
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
$settlement = $this->booker->settle(
|
||||
$reservation['ids'],
|
||||
$reservation['anchor_id'],
|
||||
$slot,
|
||||
$offering,
|
||||
$studentId,
|
||||
$noCharge
|
||||
);
|
||||
|
||||
return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* What was booked and what it left owing, so the notice answers the two things
|
||||
* the person who booked it needs to know.
|
||||
*/
|
||||
private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string {
|
||||
$who = $this->studentName( $studentId );
|
||||
|
||||
$what = $count > 1
|
||||
? sprintf(
|
||||
/* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */
|
||||
__( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ),
|
||||
$who,
|
||||
$offering->title,
|
||||
$count,
|
||||
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: 1: student name, 2: lesson type, 3: lesson date and time. */
|
||||
__( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ),
|
||||
$who,
|
||||
$offering->title,
|
||||
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
|
||||
);
|
||||
|
||||
$owing = Lesson::STATUS_CONFIRMED === $status
|
||||
? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' )
|
||||
: __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' );
|
||||
|
||||
return $what . ' ' . $owing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the form's three selects need. `$onlyInstructorId` scopes both the
|
||||
* open times and the lesson types to one instructor's, the same way `book()`
|
||||
* scopes what may be booked.
|
||||
*
|
||||
* @return array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>}
|
||||
*/
|
||||
public function formData( int $onlyInstructorId = 0 ): array {
|
||||
$slots = $this->openSlots( $onlyInstructorId );
|
||||
$offerings = array_values(
|
||||
array_filter(
|
||||
$this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ),
|
||||
static fn( Offering $o ): bool => null !== $o->id
|
||||
)
|
||||
);
|
||||
|
||||
// Whose lesson type / whose time only needs saying when the page spans more
|
||||
// than one instructor — on an instructor's own page it is noise.
|
||||
$named = 0 === $onlyInstructorId;
|
||||
|
||||
return [
|
||||
'students' => $this->studentOptions(),
|
||||
'offerings' => array_map(
|
||||
fn( Offering $o ): array => [
|
||||
'id' => (int) $o->id,
|
||||
'label' => $this->offeringLabel( $o, $named ),
|
||||
],
|
||||
$offerings
|
||||
),
|
||||
'slots' => array_map(
|
||||
fn( AvailabilitySlot $s ): array => [
|
||||
'id' => (int) $s->id,
|
||||
'label' => $this->slotLabel( $s, $named ),
|
||||
'weekly' => null !== $s->recurrenceGroup,
|
||||
],
|
||||
$slots
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open slots inside the booking horizon, newest last.
|
||||
*
|
||||
* @return list<AvailabilitySlot>
|
||||
*/
|
||||
private function openSlots( int $instructorId ): array {
|
||||
$until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) )
|
||||
->modify( '+' . self::HORIZON_DAYS . ' days' )
|
||||
->format( 'Y-m-d H:i:s' );
|
||||
|
||||
return array_values(
|
||||
array_filter(
|
||||
$this->availability->findAvailable( $instructorId, 0, 0, '', $until ),
|
||||
static fn( AvailabilitySlot $s ): bool => null !== $s->id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named
|
||||
* only when the list spans several.
|
||||
*/
|
||||
private function offeringLabel( Offering $offering, bool $withInstructor ): string {
|
||||
$label = $offering->title;
|
||||
|
||||
if ( null !== $offering->durationMinutes ) {
|
||||
/* translators: %d: lesson length in minutes. */
|
||||
$label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')';
|
||||
}
|
||||
|
||||
return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano —
|
||||
* repeats weekly": when it is, how long, whose, and what it is already tied to,
|
||||
* since all four decide whether a given student can be booked into it.
|
||||
*/
|
||||
private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string {
|
||||
/* translators: %d: lesson length in minutes. */
|
||||
$label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')';
|
||||
|
||||
if ( $withInstructor ) {
|
||||
$label .= ' — ' . $this->instructorName( $slot->instructorId );
|
||||
}
|
||||
|
||||
$tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null;
|
||||
if ( null !== $tied ) {
|
||||
$label .= ' — ' . $tied->title;
|
||||
}
|
||||
|
||||
if ( null !== $slot->recurrenceGroup ) {
|
||||
$label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function instructorName( int $instructorId ): string {
|
||||
return $this->studentName( $instructorId );
|
||||
}
|
||||
|
||||
/** A person's display name, however little the account has on file. */
|
||||
private function studentName( int $userId ): string {
|
||||
$user = get_userdata( $userId );
|
||||
|
||||
return UserName::format( $user instanceof \WP_User ? $user : null, $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone who can be booked for, by name — every holder of the student role,
|
||||
* which is exactly the set {@see book()} accepts. That deliberately includes
|
||||
* the children a guardian books for and students still awaiting approval:
|
||||
* neither may book in their own name, both may be booked for.
|
||||
*
|
||||
* @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' => UserName::format( $u, (int) $u->ID ),
|
||||
],
|
||||
$users
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+154
-129
@@ -5,9 +5,9 @@ namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
@@ -15,19 +15,16 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class BookingEndpoint {
|
||||
|
||||
/**
|
||||
* The most occurrences a single weekly booking may reserve at once, so one
|
||||
* student cannot lock up an instructor's entire recurring schedule.
|
||||
*/
|
||||
private const MAX_WEEKLY_OCCURRENCES = 12;
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $availability,
|
||||
private BookingRepository $bookings,
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private LessonBooker $booker,
|
||||
private CancellationPolicy $cancellationPolicy,
|
||||
private GuardianService $guardians,
|
||||
private SessionSchedule $sessions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,6 +56,13 @@ class BookingEndpoint {
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
],
|
||||
// Who the lesson is for. 0/absent means the caller books for
|
||||
// themselves; a child's id is honoured only for their guardian.
|
||||
'student_id' => [
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'recurrence' => [
|
||||
'type' => 'string',
|
||||
'default' => 'single',
|
||||
@@ -114,30 +118,97 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
$userId = get_current_user_id();
|
||||
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
||||
? $this->bookings->findUpcomingForInstructor( $userId )
|
||||
: $this->bookings->findUpcomingForStudent( $userId );
|
||||
$userId = get_current_user_id();
|
||||
$now = current_time( 'mysql' );
|
||||
|
||||
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 );
|
||||
// Group classes are listed here too. A term-based class has no row in
|
||||
// us_availability, so nothing that only read lessons could show one, and a
|
||||
// student whose whole week was a group class saw an empty schedule.
|
||||
if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||
$lessons = $this->bookings->findUpcomingForInstructor( $userId );
|
||||
|
||||
// One row per session the instructor teaches, not per student in it.
|
||||
$sessions = array_map(
|
||||
static fn( array $session ): array => $session + [ 'kind' => SessionSchedule::KIND ],
|
||||
$this->sessions->upcomingForInstructor( $userId, $now )
|
||||
);
|
||||
} else {
|
||||
// A guardian's list covers the whole household — their own lessons and
|
||||
// every child's — merged and re-sorted so the soonest is first
|
||||
// regardless of whose it is.
|
||||
$lessons = [];
|
||||
$sessions = [];
|
||||
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
|
||||
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
|
||||
$sessions = array_merge( $sessions, $this->sessionRows( $studentId, $now ) );
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_merge(
|
||||
array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ),
|
||||
$sessions
|
||||
);
|
||||
|
||||
// usort reindexes in place, so the response is already a list.
|
||||
usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) );
|
||||
|
||||
return new \WP_REST_Response( $rows, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One student's upcoming group-class sessions, shaped like the lesson rows
|
||||
* beside them so a single list renders both. `kind` is what tells them apart:
|
||||
* a session is not a booked slot, so it carries no cancel action.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sessionRows( int $studentId, string $now ): array {
|
||||
return array_map(
|
||||
fn( array $session ): array => $session + [
|
||||
'kind' => SessionSchedule::KIND,
|
||||
'student_name' => $this->guardians->studentName( $studentId ),
|
||||
],
|
||||
$this->sessions->upcomingForStudent( $studentId, $now )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson's array form plus its slot's start/end times and the booked
|
||||
* offering's name, so front-end lists can show what the session is and when
|
||||
* it happens without a second request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonWithTimes( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
// Prefer the offering's own length; fall back to the slot's when the
|
||||
// offering has none (a generic, duration-less type).
|
||||
$duration = null !== $offering && null !== $offering->durationMinutes
|
||||
? $offering->durationMinutes
|
||||
: $slot?->durationMinutes;
|
||||
|
||||
return $lesson->toArray() + [
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'offering_title' => $offering?->title,
|
||||
'duration_minutes' => $duration,
|
||||
// Whose lesson it is, so a guardian's merged list can say which child
|
||||
// each row belongs to.
|
||||
'student_name' => $this->guardians->studentName( $lesson->studentId ),
|
||||
];
|
||||
}
|
||||
|
||||
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
// Who the lesson is for is settled before anything else is touched: an
|
||||
// unauthorised student id must never get as far as claiming a slot, and
|
||||
// certainly never as far as raising a payment against someone's account.
|
||||
$studentId = $this->resolveStudent( $request );
|
||||
if ( $studentId instanceof \WP_Error ) {
|
||||
return $studentId;
|
||||
}
|
||||
|
||||
$slotId = Val::int( $request->get_param( 'slot_id' ) );
|
||||
$slot = $this->availability->findById( $slotId );
|
||||
|
||||
@@ -149,52 +220,12 @@ class BookingEndpoint {
|
||||
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
// Resolve the offering for this booking. A client-supplied offering must
|
||||
// never override the slot's price or payment routing: when the slot is tied
|
||||
// to a specific offering that offering is authoritative, and any offering
|
||||
// used must belong to the slot's instructor. This prevents substituting a
|
||||
// cheaper/free offering to dodge payment, or another instructor's offering
|
||||
// to misroute it.
|
||||
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
|
||||
|
||||
if ( $slotOfferingId > 0 ) {
|
||||
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
$offeringId = $slotOfferingId;
|
||||
} else {
|
||||
$offeringId = $requestedOfferingId;
|
||||
$offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) );
|
||||
if ( $offering instanceof \WP_Error ) {
|
||||
return $offering;
|
||||
}
|
||||
|
||||
// Every lesson books against an offering: it carries the price, intake
|
||||
// questions, and payment routing. Without one the booking would silently
|
||||
// be free and unquestioned, so generic slots require the student's choice.
|
||||
if ( $offeringId <= 0 ) {
|
||||
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
if ( null === $offering ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( $offering->instructorId !== $slot->instructorId ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
// A slot-tied offering was the instructor's explicit choice and is honoured
|
||||
// as-is; a student-chosen one must be something the catalog actually offers
|
||||
// for this slot: an active private-lesson type whose length fits the slot.
|
||||
if ( 0 === $slotOfferingId ) {
|
||||
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
$offeringId = (int) $offering->id;
|
||||
|
||||
$answers = $this->answers( $request );
|
||||
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
|
||||
@@ -204,72 +235,28 @@ class BookingEndpoint {
|
||||
return $gateError;
|
||||
}
|
||||
|
||||
$studentId = get_current_user_id();
|
||||
$notes = Val::string( $request->get_param( 'notes' ) );
|
||||
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
|
||||
? Lesson::RECURRENCE_WEEKLY
|
||||
: Lesson::RECURRENCE_SINGLE;
|
||||
$notes = Val::string( $request->get_param( 'notes' ) );
|
||||
|
||||
$template = new Lesson(
|
||||
slotId: $slotId,
|
||||
studentId: $studentId,
|
||||
instructorId: $slot->instructorId,
|
||||
offeringId: $offeringId,
|
||||
recurrence: $recurrence,
|
||||
notes: '' !== $notes ? $notes : null,
|
||||
$reservation = $this->booker->reserve(
|
||||
$slot,
|
||||
$offering,
|
||||
$studentId,
|
||||
Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
|
||||
$notes
|
||||
);
|
||||
|
||||
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
|
||||
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
|
||||
// Claim each occurrence atomically (capped so one booking cannot lock an
|
||||
// instructor's entire schedule), then create a lesson only for the slots
|
||||
// this request actually won — never for one already taken by someone else.
|
||||
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
|
||||
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
|
||||
|
||||
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
|
||||
if ( [] === $claimed ) {
|
||||
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
$ids = $this->bookings->insertSeries( $template, $claimed );
|
||||
$anchorId = $ids[0] ?? 0;
|
||||
} else {
|
||||
// Claim before inserting: if another request already took the slot, the
|
||||
// guarded update reports no rows and we reject rather than double-book.
|
||||
if ( ! $this->availability->claim( $slotId ) ) {
|
||||
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
$anchorId = $this->bookings->insert( $template );
|
||||
$ids = [ $anchorId ];
|
||||
if ( $reservation instanceof \WP_Error ) {
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
$ids = $reservation['ids'];
|
||||
$anchorId = $reservation['anchor_id'];
|
||||
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
// The acceptance binds the student but is attributed to whoever actually
|
||||
// ticked the boxes — the guardian, when they booked for a child.
|
||||
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
// 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.
|
||||
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
|
||||
? $offering->price
|
||||
: $offering->price * count( $ids );
|
||||
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail );
|
||||
|
||||
if ( null !== $payment && $payment->isPaid() ) {
|
||||
$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.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
[ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId );
|
||||
|
||||
// `payment: null` tells the front end to skip the payment step entirely.
|
||||
return new \WP_REST_Response(
|
||||
@@ -282,6 +269,35 @@ class BookingEndpoint {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this booking is for: the caller by default, or one of their children
|
||||
* when a `student_id` is supplied and they are that child's guardian.
|
||||
*
|
||||
* This is the authorisation boundary of guardian booking — without it any
|
||||
* signed-in student could book, and bill, against any user id they chose to
|
||||
* send. An id the caller may not act for is a 403, never a silent fallback to
|
||||
* themselves: a guardian who picked the wrong child needs to be told, not to
|
||||
* have the lesson quietly booked in their own name.
|
||||
*/
|
||||
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
|
||||
$userId = get_current_user_id();
|
||||
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
|
||||
|
||||
if ( $requested <= 0 || $requested === $userId ) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
|
||||
return new \WP_Error(
|
||||
'forbidden',
|
||||
__( 'You cannot book on behalf of that student.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
return $requested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a question_id => value map from the request.
|
||||
*
|
||||
@@ -304,22 +320,29 @@ 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.
|
||||
* Student-initiated cancellation of their own lesson — or a guardian's, of one
|
||||
* of their children's: marks it cancelled,
|
||||
* 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' ) ) );
|
||||
$lesson = $this->bookings->findById( $id );
|
||||
|
||||
if ( null === $lesson ) {
|
||||
// A booking that is not the caller's is answered exactly as one that does
|
||||
// not exist. Telling the two apart — 403 here, 404 there — would let any
|
||||
// signed-in student walk the id space and learn which lessons the studio
|
||||
// holds, and roughly how many. There is nothing a student can do with
|
||||
// either answer, so there is no reason to distinguish them.
|
||||
//
|
||||
// The booking form's own 403 (see resolveStudent) is a different case: the
|
||||
// student id there was chosen from a list of people the caller may act for,
|
||||
// so "not yours" is a correction they need, not a fact they lack.
|
||||
if ( null === $lesson || ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
if ( get_current_user_id() !== $lesson->studentId ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
if ( null !== $slot ) {
|
||||
@@ -341,6 +364,7 @@ class BookingEndpoint {
|
||||
$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(
|
||||
@@ -369,6 +393,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.
|
||||
|
||||
@@ -5,15 +5,37 @@ namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class BookingPage {
|
||||
|
||||
/** Booking calendar and the student's upcoming lessons (the default). */
|
||||
public const MODE_BOTH = 'both';
|
||||
|
||||
/** Booking calendar only — no upcoming-lessons panel. */
|
||||
public const MODE_BOOKING = 'booking';
|
||||
|
||||
/** The student's upcoming lessons only — nothing bookable. */
|
||||
public const MODE_UPCOMING = 'upcoming';
|
||||
|
||||
public function __construct( private GuardianService $guardians ) {}
|
||||
|
||||
/**
|
||||
* Renders the booking shortcode/block output.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
* Supported attributes (block / shortcode form):
|
||||
* - `loginPageId` / `login_page_id` — where logged-out visitors are sent.
|
||||
* - `lessonTypeId` / `lesson_type` — a private-lesson offering id that pins
|
||||
* the calendar to one lesson type: only the times bookable as that type
|
||||
* are listed, and only it can be booked. 0 or absent shows every type.
|
||||
* - `showTypeFilter` / `show_filter` — whether the "Show Only" lesson-type
|
||||
* filter is offered (default true; irrelevant when a type is pinned).
|
||||
* - `displayMode` / `show` — which halves of the page to embed:
|
||||
* {@see self::MODE_BOTH} (default), {@see self::MODE_BOOKING} (calendar
|
||||
* only) or {@see self::MODE_UPCOMING} (the student's lessons only).
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block or shortcode attributes.
|
||||
*/
|
||||
public function render( array $atts ): string {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
@@ -38,11 +60,47 @@ class BookingPage {
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
wp_enqueue_script( 'us-scheduler' );
|
||||
|
||||
$lessonTypeId = absint( Val::int( $atts['lessonTypeId'] ?? $atts['lesson_type'] ?? 0 ) );
|
||||
$showTypeFilter = self::toBool( $atts['showTypeFilter'] ?? $atts['show_filter'] ?? true );
|
||||
|
||||
$mode = self::mode( $atts['displayMode'] ?? $atts['show'] ?? self::MODE_BOTH );
|
||||
$showBooking = self::MODE_UPCOMING !== $mode;
|
||||
$showUpcoming = self::MODE_BOOKING !== $mode;
|
||||
|
||||
// Who this account may book for. A single-student account gets one entry
|
||||
// (themselves) and no selector at all; a guardian's list leads with their
|
||||
// children, so the default choice is never the parent.
|
||||
$students = $this->guardians->bookableStudents( get_current_user_id() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises the display-mode attribute; anything unrecognised embeds the
|
||||
* whole page, so a typo never silently hides half of it.
|
||||
*/
|
||||
private static function mode( mixed $value ): string {
|
||||
$mode = strtolower( trim( Val::string( $value ) ) );
|
||||
|
||||
return in_array( $mode, [ self::MODE_BOOKING, self::MODE_UPCOMING ], true ) ? $mode : self::MODE_BOTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a boolean attribute. Block attributes arrive as real booleans,
|
||||
* shortcode attributes as strings — where the words people actually write
|
||||
* for "off" ("no", "false", "off") are all truthy to PHP, so they are
|
||||
* matched explicitly rather than cast.
|
||||
*/
|
||||
private static function toBool( mixed $value ): bool {
|
||||
if ( is_string( $value ) ) {
|
||||
return ! in_array( strtolower( trim( $value ) ), [ '', '0', 'no', 'false', 'off' ], true );
|
||||
}
|
||||
|
||||
return Val::bool( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* URL the logged-out prompt sends visitors to: the chosen login page when
|
||||
* one is configured (and still exists), otherwise the WordPress login
|
||||
|
||||
@@ -24,9 +24,10 @@ class BookingRepository {
|
||||
'status' => $lesson->status,
|
||||
'payment_id' => $lesson->paymentId,
|
||||
'notes' => $lesson->notes,
|
||||
'booked_by' => $lesson->bookedBy,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ]
|
||||
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -54,6 +55,7 @@ class BookingRepository {
|
||||
seriesId: $seriesId > 0 ? $seriesId : null,
|
||||
status: $template->status,
|
||||
notes: $template->notes,
|
||||
bookedBy: $template->bookedBy,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -204,6 +206,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,
|
||||
|
||||
+43
-1
@@ -3,9 +3,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\IntakeSubject;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class Lesson {
|
||||
class Lesson implements IntakeSubject {
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
@@ -38,9 +40,47 @@ class Lesson {
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
public readonly ?int $paymentId = null,
|
||||
public readonly ?string $notes = null,
|
||||
/**
|
||||
* The staff member who booked this lesson on the student's behalf, from
|
||||
* wp-admin; 0 when it was booked through the student-facing flow, by the
|
||||
* student or their guardian. It is what marks a lesson whose intake answers
|
||||
* and policy acceptances may be recorded after the fact — nobody was at a
|
||||
* keyboard to give them at booking time.
|
||||
*/
|
||||
public readonly int $bookedBy = 0,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public function intakeRegistrationType(): string {
|
||||
return Answer::REG_LESSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lesson id this booking's intake answers and policy acceptances hang
|
||||
* off: the series anchor for a weekly reservation, the lesson itself
|
||||
* otherwise. A series is answered for and agreed to once, so every occurrence
|
||||
* reads and writes the same registration.
|
||||
*/
|
||||
public function intakeRegistrationId(): int {
|
||||
return $this->seriesId ?? (int) $this->id;
|
||||
}
|
||||
|
||||
public function intakeOfferingId(): int {
|
||||
return (int) $this->offeringId;
|
||||
}
|
||||
|
||||
public function intakeStudentId(): int {
|
||||
return $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the studio booked this lesson on the student's behalf, rather than
|
||||
* the student (or their guardian) booking it themselves.
|
||||
*/
|
||||
public function isStaffRegistered(): bool {
|
||||
return $this->bookedBy > 0;
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
slotId: Val::int( $row->slot_id ),
|
||||
@@ -52,6 +92,7 @@ class Lesson {
|
||||
status: Val::string( $row->status ),
|
||||
paymentId: Val::intOrNull( $row->payment_id ),
|
||||
notes: Val::stringOrNull( $row->notes ),
|
||||
bookedBy: Val::int( $row->booked_by ?? 0 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -73,6 +114,7 @@ class Lesson {
|
||||
'status' => $this->status,
|
||||
'payment_id' => $this->paymentId,
|
||||
'notes' => $this->notes,
|
||||
'booked_by' => $this->bookedBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The booking core shared by the student-facing REST endpoint and the admin
|
||||
* "book a lesson for a student" form: which offering a slot may be booked as,
|
||||
* claiming the slot(s) and writing the lesson row(s), and raising the payment
|
||||
* that confirms them.
|
||||
*
|
||||
* It deliberately knows nothing about who is asking. Authorisation — a guardian
|
||||
* booking for their own child, a studio admin booking for anyone — is settled by
|
||||
* the caller before anything here is touched, and so are the intake answers and
|
||||
* policy acceptances that gate a student's own booking (an admin booking on
|
||||
* someone's behalf has none to collect). What must not diverge between the two
|
||||
* paths is everything below: the offering rules that decide a slot's price and
|
||||
* payment routing, the atomic claim that stops a double-booking, and the billing
|
||||
* that follows.
|
||||
*/
|
||||
class LessonBooker {
|
||||
|
||||
/**
|
||||
* The most occurrences a single weekly booking may reserve at once, so one
|
||||
* student cannot lock up an instructor's entire recurring schedule.
|
||||
*/
|
||||
public const MAX_WEEKLY_OCCURRENCES = 12;
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $availability,
|
||||
private BookingRepository $bookings,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentService $payments,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the offering a slot is to be booked as. A caller-supplied offering
|
||||
* must never override the slot's price or payment routing: when the slot is
|
||||
* tied to a specific offering that offering is authoritative, and any offering
|
||||
* used must belong to the slot's instructor. This prevents substituting a
|
||||
* cheaper/free offering to dodge payment, or another instructor's offering to
|
||||
* misroute it.
|
||||
*/
|
||||
public function resolveOffering( AvailabilitySlot $slot, int $requestedOfferingId ): Offering|\WP_Error {
|
||||
$requestedOfferingId = absint( $requestedOfferingId );
|
||||
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
|
||||
|
||||
if ( $slotOfferingId > 0 ) {
|
||||
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
$offeringId = $slotOfferingId;
|
||||
} else {
|
||||
$offeringId = $requestedOfferingId;
|
||||
}
|
||||
|
||||
// Every lesson books against an offering: it carries the price, intake
|
||||
// questions, and payment routing. Without one the booking would silently
|
||||
// be free and unquestioned, so generic slots require an explicit choice.
|
||||
if ( $offeringId <= 0 ) {
|
||||
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
if ( null === $offering ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( $offering->instructorId !== $slot->instructorId ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
// A slot-tied offering was the instructor's explicit choice and is honoured
|
||||
// as-is; a chosen one must be something the catalog actually offers for this
|
||||
// slot: an active private-lesson type whose length fits the slot.
|
||||
if ( 0 === $slotOfferingId ) {
|
||||
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
|
||||
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
|
||||
return $offering;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the slot(s) and write the lesson row(s) — a single lesson, or one per
|
||||
* remaining occurrence of the slot's weekly group. The rows are created
|
||||
* `pending`; `settle()` decides what confirms them.
|
||||
*
|
||||
* `$bookedBy` is the staff member booking on the student's behalf, and 0 for a
|
||||
* booking made through the student-facing flow. It is recorded on every lesson
|
||||
* of a series, since a series is booked once.
|
||||
*
|
||||
* @return array{ids: list<int>, anchor_id: int}|\WP_Error
|
||||
*/
|
||||
public function reserve( AvailabilitySlot $slot, Offering $offering, int $studentId, string $recurrence, ?string $notes = null, int $bookedBy = 0 ): array|\WP_Error {
|
||||
$slotId = (int) $slot->id;
|
||||
$recurrence = Lesson::RECURRENCE_WEEKLY === $recurrence ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE;
|
||||
|
||||
$template = new Lesson(
|
||||
slotId: $slotId,
|
||||
studentId: $studentId,
|
||||
instructorId: $slot->instructorId,
|
||||
offeringId: (int) $offering->id,
|
||||
recurrence: $recurrence,
|
||||
notes: null !== $notes && '' !== $notes ? $notes : null,
|
||||
bookedBy: $bookedBy,
|
||||
);
|
||||
|
||||
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
|
||||
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
|
||||
// Claim each occurrence atomically (capped so one booking cannot lock an
|
||||
// instructor's entire schedule), then create a lesson only for the slots
|
||||
// this request actually won — never for one already taken by someone else.
|
||||
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
|
||||
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
|
||||
|
||||
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
|
||||
if ( [] === $claimed ) {
|
||||
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
$ids = $this->bookings->insertSeries( $template, $claimed );
|
||||
|
||||
return [
|
||||
'ids' => $ids,
|
||||
'anchor_id' => $ids[0] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Claim before inserting: if another request already took the slot, the
|
||||
// guarded update reports no rows and we reject rather than double-book.
|
||||
if ( ! $this->availability->claim( $slotId ) ) {
|
||||
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
$anchorId = $this->bookings->insert( $template );
|
||||
|
||||
return [
|
||||
'ids' => [ $anchorId ],
|
||||
'anchor_id' => $anchorId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the payment for a reservation and report the status its lessons end up
|
||||
* in. A priced booking stays `pending` until its payment settles; anything with
|
||||
* nothing to charge now — a free offering, scheduled billing, or a booking the
|
||||
* caller marked `$noCharge` — is confirmed here and then.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return array{status: string, payment: ?Payment}
|
||||
*/
|
||||
public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array {
|
||||
// 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 = ! $noCharge && $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.
|
||||
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
|
||||
? $offering->price
|
||||
: $offering->price * count( $ids );
|
||||
|
||||
$payment = $this->payments->createForRegistration(
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$studentId,
|
||||
$slot->instructorId,
|
||||
$amount,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
payerId: $this->guardians->payerFor( $studentId )
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
|
||||
'payment' => $payment,
|
||||
];
|
||||
}
|
||||
|
||||
// Either nothing is owed — a free offering, or a booking the studio comped —
|
||||
// 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 the scheduled ones when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => Lesson::STATUS_CONFIRMED,
|
||||
'payment' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a lesson has already
|
||||
* gone by — monthly bills on the first of the lesson's month, weekly the day
|
||||
* before the lesson.
|
||||
*/
|
||||
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' );
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ 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\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class LessonController {
|
||||
@@ -17,6 +21,10 @@ class LessonController {
|
||||
private BookingRepository $repository,
|
||||
private PaymentRepository $payments,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private IntakeAudit $detail,
|
||||
private AdminBooking $adminBooking,
|
||||
private IntakeRecording $intake,
|
||||
) {}
|
||||
|
||||
public function renderAdminDashboard(): void {
|
||||
@@ -24,11 +32,15 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( false );
|
||||
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
[ $notice, $error ] = $this->handleFormAction( false, 0 );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
|
||||
|
||||
$this->renderLessonsPage( $rows, 'us-scheduler' );
|
||||
$this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error );
|
||||
}
|
||||
|
||||
public function renderInstructorLessons(): void {
|
||||
@@ -36,20 +48,137 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( true );
|
||||
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
|
||||
$instructorId = get_current_user_id();
|
||||
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons' );
|
||||
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
|
||||
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
$notice = '';
|
||||
$error = '';
|
||||
|
||||
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
|
||||
$row = null;
|
||||
$answers = [];
|
||||
$accepts = [];
|
||||
$intake = $this->emptyIntake();
|
||||
} else {
|
||||
// Recorded before the tables are read, so what was just entered appears
|
||||
// on the page that reports it.
|
||||
[ $notice, $error ] = $this->recordIntake( $lesson );
|
||||
|
||||
$row = $this->row( $lesson );
|
||||
$answers = $this->detail->answers( $lesson );
|
||||
$accepts = $this->detail->acceptances( $lesson );
|
||||
$intake = $this->intakeForm( $lesson );
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a submitted "record intake collected elsewhere" form.
|
||||
*
|
||||
* @return array{string, string} Success notice and error message.
|
||||
*/
|
||||
private function recordIntake( Lesson $lesson ): array {
|
||||
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
|
||||
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
|
||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
|
||||
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
|
||||
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
|
||||
|
||||
$result = $this->intake->record(
|
||||
$lesson,
|
||||
$answers,
|
||||
$versionIds,
|
||||
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
|
||||
get_current_user_id()
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return $result instanceof \WP_Error
|
||||
? [ '', $result->get_error_message() ]
|
||||
: [ $result, '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the detail template needs to offer the recording form: whether this
|
||||
* lesson qualifies at all, what is still missing, and the collection methods
|
||||
* to choose between.
|
||||
*
|
||||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||||
*/
|
||||
private function intakeForm( Lesson $lesson ): array {
|
||||
if ( ! $lesson->isStaffRegistered() ) {
|
||||
return $this->emptyIntake();
|
||||
}
|
||||
|
||||
return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ];
|
||||
}
|
||||
|
||||
/**
|
||||
* The form data for a lesson that cannot be recorded against — one the student
|
||||
* booked, or one that could not be opened at all.
|
||||
*
|
||||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||||
*/
|
||||
private function emptyIntake(): array {
|
||||
return [
|
||||
'recordable' => false,
|
||||
'questions' => [],
|
||||
'policies' => [],
|
||||
'methods' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the lessons template with its calendar view state: week (default)
|
||||
* or list, plus which week the week view shows.
|
||||
* or list, plus which week the week view shows, and the choices the
|
||||
* book-for-a-student form offers — scoped to one instructor's own schedule on
|
||||
* **My Lessons**, studio-wide (0) on the **Scheduler**.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderLessonsPage( array $rows, string $pageSlug ): void {
|
||||
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice is read by the included template.
|
||||
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
|
||||
// View-state query params only (which view, which week) — nothing is
|
||||
// mutated from them, so no nonce applies.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
@@ -62,21 +191,108 @@ class LessonController {
|
||||
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
|
||||
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
|
||||
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
||||
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
|
||||
|
||||
// A refused booking is shown again as it was typed — losing five fields to a
|
||||
// single mistake is what made the panel infuriating to correct. A successful
|
||||
// one starts empty, so the next booking does not inherit the last one's.
|
||||
$bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a per-lesson payment override (e-transfer email or HST rate). When
|
||||
* $onlyOwn, the payment must belong to the current instructor.
|
||||
* Run the submitted action and report what happened: a per-lesson payment
|
||||
* override (e-transfer email or HST rate), or a lesson booked for a student.
|
||||
* When $onlyOwn, the payment or slot must belong to the current instructor.
|
||||
*
|
||||
* @return array{string, string} Success notice and error message; each is
|
||||
* empty when it does not apply.
|
||||
*/
|
||||
private function handleEtransferUpdate( bool $onlyOwn ): void {
|
||||
private function handleFormAction( bool $onlyOwn, int $instructorId ): array {
|
||||
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
|
||||
return;
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
|
||||
|
||||
if ( 'book_for_student' === $action ) {
|
||||
return $this->bookForStudent( $onlyOwn ? $instructorId : 0 );
|
||||
}
|
||||
|
||||
$this->updatePayment( $action, $onlyOwn );
|
||||
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a lesson on a student's behalf from the submitted form. The slot is
|
||||
* scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId
|
||||
* non-zero) and studio-wide on the **Scheduler**.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function bookForStudent( int $onlyInstructorId ): array {
|
||||
$submitted = $this->submittedBooking();
|
||||
|
||||
$result = $this->adminBooking->book(
|
||||
$submitted['student_id'],
|
||||
$submitted['slot_id'],
|
||||
$submitted['offering_id'],
|
||||
$submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
|
||||
$submitted['no_charge'],
|
||||
$submitted['notes'],
|
||||
$onlyInstructorId
|
||||
);
|
||||
|
||||
return $result instanceof \WP_Error
|
||||
? [ '', $result->get_error_message() ]
|
||||
: [ $result, '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* The book-for-a-student form exactly as submitted. Read in one place so what
|
||||
* gets booked and what the form shows again after a refusal cannot drift apart
|
||||
* on a field name.
|
||||
*
|
||||
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
|
||||
*/
|
||||
private function submittedBooking(): array {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused.
|
||||
return [
|
||||
'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ),
|
||||
'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
|
||||
'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
||||
'weekly' => isset( $_POST['recurrence_weekly'] ),
|
||||
'no_charge' => isset( $_POST['no_charge'] ),
|
||||
'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
|
||||
];
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
/**
|
||||
* An untouched book-for-a-student form.
|
||||
*
|
||||
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
|
||||
*/
|
||||
private function emptyBooking(): array {
|
||||
return [
|
||||
'student_id' => 0,
|
||||
'slot_id' => 0,
|
||||
'offering_id' => 0,
|
||||
'weekly' => false,
|
||||
'no_charge' => false,
|
||||
'notes' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
|
||||
* to the current instructor.
|
||||
*/
|
||||
private function updatePayment( string $action, bool $onlyOwn ): void {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
|
||||
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
|
||||
@@ -111,10 +327,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 ) ) : '—',
|
||||
|
||||
@@ -3,9 +3,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\IntakeSubject;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class Enrollment {
|
||||
class Enrollment implements IntakeSubject {
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
@@ -24,9 +26,45 @@ class Enrollment {
|
||||
public readonly int $instructorId,
|
||||
public readonly string $status = self::STATUS_ACTIVE,
|
||||
public readonly ?int $paymentId = null,
|
||||
/**
|
||||
* The staff member who enrolled this student from wp-admin — the class
|
||||
* detail page's **Add students directly**; 0 when the student or their
|
||||
* guardian enrolled themselves. It is what marks an enrolment whose intake
|
||||
* answers and policy acceptances may be recorded after the fact, nobody
|
||||
* having been at a keyboard to give them at the time.
|
||||
*/
|
||||
public readonly int $enrolledBy = 0,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public function intakeRegistrationType(): string {
|
||||
return Answer::REG_ENROLLMENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* An enrolment is registered once and is its own registration — there is no
|
||||
* series anchor to follow, as a term of classes is one enrolment.
|
||||
*/
|
||||
public function intakeRegistrationId(): int {
|
||||
return (int) $this->id;
|
||||
}
|
||||
|
||||
public function intakeOfferingId(): int {
|
||||
return $this->offeringId;
|
||||
}
|
||||
|
||||
public function intakeStudentId(): int {
|
||||
return $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the studio enrolled this student, rather than the student (or their
|
||||
* guardian) enrolling themselves.
|
||||
*/
|
||||
public function isStaffRegistered(): bool {
|
||||
return $this->enrolledBy > 0;
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
offeringId: Val::int( $row->offering_id ),
|
||||
@@ -34,6 +72,7 @@ class Enrollment {
|
||||
instructorId: Val::int( $row->instructor_id ),
|
||||
status: Val::string( $row->status ),
|
||||
paymentId: Val::intOrNull( $row->payment_id ),
|
||||
enrolledBy: Val::int( $row->enrolled_by ?? 0 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -51,6 +90,7 @@ class Enrollment {
|
||||
'instructor_id' => $this->instructorId,
|
||||
'status' => $this->status,
|
||||
'payment_id' => $this->paymentId,
|
||||
'enrolled_by' => $this->enrolledBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -20,6 +21,7 @@ class EnrollmentEndpoint {
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,13 @@ class EnrollmentEndpoint {
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
// Who is being enrolled. 0/absent means the caller enrols
|
||||
// themselves; a child's id is honoured only for their guardian.
|
||||
'student_id' => [
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'answers' => [
|
||||
'type' => 'object',
|
||||
'default' => [],
|
||||
@@ -59,6 +68,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
|
||||
@@ -69,13 +90,25 @@ class EnrollmentEndpoint {
|
||||
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||
$enrollments = $this->enrollments->findByInstructor( $userId );
|
||||
} else {
|
||||
$enrollments = $this->enrollments->findByStudent( $userId );
|
||||
// A guardian sees the whole household's enrolments — their own and
|
||||
// every child's — so one account covers the family.
|
||||
$enrollments = [];
|
||||
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
|
||||
$enrollments = array_merge( $enrollments, $this->enrollments->findByStudent( $studentId ) );
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
|
||||
}
|
||||
|
||||
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
// Who is being enrolled is settled before anything else, so an
|
||||
// unauthorised student id never reaches a seat claim or a charge.
|
||||
$studentId = $this->resolveStudent( $request );
|
||||
if ( $studentId instanceof \WP_Error ) {
|
||||
return $studentId;
|
||||
}
|
||||
|
||||
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
|
||||
@@ -83,8 +116,6 @@ class EnrollmentEndpoint {
|
||||
return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
$studentId = get_current_user_id();
|
||||
|
||||
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
|
||||
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
@@ -95,6 +126,12 @@ class EnrollmentEndpoint {
|
||||
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day — the instructor's set
|
||||
// deadline, or the first class day by default.
|
||||
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
|
||||
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
|
||||
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
@@ -115,7 +152,9 @@ class EnrollmentEndpoint {
|
||||
)
|
||||
);
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
// The acceptance binds the student but is attributed to whoever ticked the
|
||||
// boxes — the guardian, when they enrolled a child.
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
|
||||
|
||||
// Mark the access grant used so instructor rosters distinguish invited
|
||||
// students from enrolled ones (a no-op for public classes).
|
||||
@@ -123,9 +162,21 @@ class EnrollmentEndpoint {
|
||||
$this->access->markEnrolled( $offeringId, $studentId );
|
||||
}
|
||||
|
||||
// Scheduled billing (weekly / monthly) is generated later by the daily
|
||||
// billing scan, so nothing is charged at enrolment; the enrolment is active
|
||||
// regardless of payment.
|
||||
$payment = null;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
||||
$payment = $this->payments->createForRegistration(
|
||||
Payment::REG_ENROLLMENT,
|
||||
$id,
|
||||
$studentId,
|
||||
$offering->instructorId,
|
||||
$offering->price,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
payerId: $this->guardians->payerFor( $studentId )
|
||||
);
|
||||
}
|
||||
|
||||
// `payment: null` tells the front end to skip the payment step entirely.
|
||||
@@ -139,6 +190,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 );
|
||||
|
||||
// Someone else's enrolment is answered exactly as a nonexistent one, so the
|
||||
// id space cannot be walked to count the studio's enrolments. See
|
||||
// {@see \Unsupervised\Schedular\Booking\BookingEndpoint::cancel()}, which
|
||||
// makes the same trade for the same reason.
|
||||
if ( null === $enrollment || ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
|
||||
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -147,6 +242,30 @@ class EnrollmentEndpoint {
|
||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this enrolment is for: the caller by default, or one of their children
|
||||
* when a `student_id` is supplied and they are that child's guardian. An id
|
||||
* the caller may not act for is a 403, never a silent fallback to themselves.
|
||||
*/
|
||||
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
|
||||
$userId = get_current_user_id();
|
||||
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
|
||||
|
||||
if ( $requested <= 0 || $requested === $userId ) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
|
||||
return new \WP_Error(
|
||||
'forbidden',
|
||||
__( 'You cannot enrol that student.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
return $requested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a question_id => value map from the request.
|
||||
*
|
||||
|
||||
@@ -20,9 +20,10 @@ class EnrollmentRepository {
|
||||
'instructor_id' => $enrollment->instructorId,
|
||||
'status' => $enrollment->status,
|
||||
'payment_id' => $enrollment->paymentId,
|
||||
'enrolled_by' => $enrollment->enrolledBy,
|
||||
'enrolled_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -132,6 +133,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,
|
||||
|
||||
@@ -14,6 +14,9 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class GroupClassController {
|
||||
@@ -26,6 +29,8 @@ class GroupClassController {
|
||||
private PaymentService $paymentService,
|
||||
private InviteRepository $invites,
|
||||
private RegistrationMailer $mailer,
|
||||
private IntakeAudit $audit,
|
||||
private IntakeRecording $intake,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,13 +46,18 @@ class GroupClassController {
|
||||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
|
||||
|
||||
if ( $this->maybeRenderEnrollmentDetail( $baseUrl, 0 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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.
|
||||
@@ -104,6 +114,10 @@ class GroupClassController {
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
|
||||
if ( $this->maybeRenderEnrollmentDetail( admin_url( 'admin.php?page=us-my-group-classes' ), $instructorId ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||
$notice = $this->handleFormAction( $instructorId );
|
||||
@@ -144,6 +158,138 @@ class GroupClassController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* When the request targets a single enrolment (`?enrollment_id=`), render its
|
||||
* detail view — the audit trail of what the student answered and agreed to,
|
||||
* and, for an enrolment the studio made, the form to record intake collected
|
||||
* elsewhere. Reports whether the page has been handled.
|
||||
*
|
||||
* `$onlyInstructorId` scopes it the way the pages themselves are scoped: an
|
||||
* instructor may only open enrolments in their own classes, while the studio
|
||||
* **Group Classes** page passes 0 and may open any.
|
||||
*/
|
||||
private function maybeRenderEnrollmentDetail( string $baseUrl, int $onlyInstructorId ): bool {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only enrolment selector.
|
||||
$enrollmentId = absint( Val::int( $_GET['enrollment_id'] ?? 0 ) );
|
||||
if ( $enrollmentId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$enrollment = $this->enrollments->findById( $enrollmentId );
|
||||
$notice = '';
|
||||
$error = '';
|
||||
|
||||
if ( null === $enrollment || ( $onlyInstructorId > 0 && $enrollment->instructorId !== $onlyInstructorId ) ) {
|
||||
$row = null;
|
||||
$answers = [];
|
||||
$accepts = [];
|
||||
$intake = $this->emptyIntake();
|
||||
} else {
|
||||
// Recorded before the tables are read, so what was just entered appears
|
||||
// on the page that reports it.
|
||||
[ $notice, $error ] = $this->recordIntake( $enrollment );
|
||||
|
||||
$row = $this->enrollmentRow( $enrollment );
|
||||
$answers = $this->audit->answers( $enrollment );
|
||||
$accepts = $this->audit->acceptances( $enrollment );
|
||||
$intake = $this->intakeForm( $enrollment );
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/enrollment-detail.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who and what one enrolment is, for the head of its detail view.
|
||||
*
|
||||
* @return array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}
|
||||
*/
|
||||
private function enrollmentRow( Enrollment $enrollment ): array {
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||||
|
||||
return [
|
||||
'enrollment_id' => (int) $enrollment->id,
|
||||
'student' => UserName::format( $student instanceof \WP_User ? $student : null, $enrollment->studentId ),
|
||||
'class' => null !== $offering ? $offering->title : '—',
|
||||
'instructor' => null !== $offering ? $this->instructorName( $offering ) : '—',
|
||||
'status' => $enrollment->status,
|
||||
'payment' => null !== $payment ? $payment->status : '—',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a submitted "record intake collected elsewhere" form.
|
||||
*
|
||||
* @return array{string, string} Success notice and error message.
|
||||
*/
|
||||
private function recordIntake( Enrollment $enrollment ): array {
|
||||
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_group_action' ) ) {
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||||
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
|
||||
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
|
||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
|
||||
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
|
||||
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
|
||||
|
||||
$result = $this->intake->record(
|
||||
$enrollment,
|
||||
$answers,
|
||||
$versionIds,
|
||||
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
|
||||
get_current_user_id()
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return $result instanceof \WP_Error
|
||||
? [ '', $result->get_error_message() ]
|
||||
: [ $result, '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the detail template needs to offer the recording form: whether this
|
||||
* enrolment qualifies at all, what is still missing, and the collection
|
||||
* methods to choose between.
|
||||
*
|
||||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||||
*/
|
||||
private function intakeForm( Enrollment $enrollment ): array {
|
||||
if ( ! $enrollment->isStaffRegistered() ) {
|
||||
return $this->emptyIntake();
|
||||
}
|
||||
|
||||
return [ 'recordable' => true ] + $this->intake->pending( $enrollment ) + [ 'methods' => IntakeProvenance::choices() ];
|
||||
}
|
||||
|
||||
/**
|
||||
* The form data for an enrolment that cannot be recorded against — one the
|
||||
* student made, or one that could not be opened at all.
|
||||
*
|
||||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||||
*/
|
||||
private function emptyIntake(): array {
|
||||
return [
|
||||
'recordable' => false,
|
||||
'questions' => [],
|
||||
'policies' => [],
|
||||
'methods' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary row for one class in the instructor overview: its identity, when it
|
||||
* meets, and how many active enrolments it holds against capacity.
|
||||
@@ -176,7 +322,7 @@ class GroupClassController {
|
||||
* invite-only classes — the list of people invited but not yet enrolled.
|
||||
*
|
||||
* @param list<Enrollment> $enrollments
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{id: int, student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
*/
|
||||
private function classDetail( Offering $offering, array $enrollments ): array {
|
||||
$roster = [];
|
||||
@@ -189,22 +335,27 @@ class GroupClassController {
|
||||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||||
|
||||
$roster[] = [
|
||||
'id' => (int) $enrollment->id,
|
||||
'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,
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'duration' => $offering->durationMinutes,
|
||||
'description' => $offering->description,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
|
||||
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
|
||||
'active' => $offering->isActive,
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -312,6 +463,10 @@ class GroupClassController {
|
||||
* Directly enrol registered students, each with a pending payment at the
|
||||
* class price (comp students are settled immediately by the payment service).
|
||||
*
|
||||
* This is the instructor's manual enrolment path and deliberately bypasses the
|
||||
* enrolment deadline and capacity, so a student can be added as a late
|
||||
* enrolment after the class has closed to self-enrolment.
|
||||
*
|
||||
* @param list<int> $studentIds
|
||||
*/
|
||||
private function addDirect( Offering $offering, array $studentIds ): string {
|
||||
@@ -326,6 +481,10 @@ class GroupClassController {
|
||||
offeringId: (int) $offering->id,
|
||||
studentId: $studentId,
|
||||
instructorId: $offering->instructorId,
|
||||
// Stamped so this enrolment can be told apart later: only one the
|
||||
// studio made may have its intake recorded after the fact, the
|
||||
// student never having been asked the questions.
|
||||
enrolledBy: get_current_user_id(),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -481,7 +640,15 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
/**
|
||||
* The de-duplicated positive student ids posted from a multi-select.
|
||||
* The de-duplicated student ids posted from a multi-select, keeping only ids
|
||||
* that are actually students.
|
||||
*
|
||||
* The select is built from {@see studentOptions()}, but nothing stops a posted
|
||||
* id naming an instructor, an administrator, or an account deleted since the
|
||||
* page was drawn — and enrolling one would write a roster row, and bill it,
|
||||
* against someone who is not in the class. Vetting here covers both actions at
|
||||
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
|
||||
* so a child or an unapproved signup is still perfectly enrollable.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
@@ -491,7 +658,7 @@ class GroupClassController {
|
||||
$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 ) );
|
||||
return array_values( array_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class GroupClassPage {
|
||||
|
||||
public function __construct( private GuardianService $guardians ) {}
|
||||
|
||||
/**
|
||||
* Renders the group-class enrolment shortcode output.
|
||||
*
|
||||
@@ -39,6 +42,10 @@ class GroupClassPage {
|
||||
|
||||
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
|
||||
|
||||
// Who this account may enrol — children first, the account holder last, so
|
||||
// a guardian's default choice is a child rather than themselves.
|
||||
$students = $this->guardians->bookableStudents( get_current_user_id() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
||||
return (string) ob_get_clean();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
|
||||
/**
|
||||
* Turns group-class enrolments into dated sessions, so a class can appear
|
||||
* alongside one-to-one lessons in every "upcoming" view.
|
||||
*
|
||||
* A group class is stored as a term (`term_start`, `term_end`, `class_time`)
|
||||
* rather than as rows in `us_availability`, which is why an enrolment on its own
|
||||
* has no date on it and why nothing that listed lessons ever showed one. The
|
||||
* dates come from {@see Offering::sessionStarts()} — the same derivation the
|
||||
* billing scan and the class-slot reconciler build on, so a student's list, an
|
||||
* instructor's list and the invoice all agree on when the class meets.
|
||||
*
|
||||
* **A class you are enrolled in must never silently vanish from the list.** Both
|
||||
* the class time and the duration are optional on the offering form, and the
|
||||
* schedule note exists precisely so a studio can write "Tuesdays 4:00pm" instead
|
||||
* of pinning the class to a clock. So the schedule degrades rather than
|
||||
* disappearing:
|
||||
*
|
||||
* - date **and** time set — one dated row per remaining session, closed off with
|
||||
* the duration when there is one and left open-ended when there is not;
|
||||
* - no time to derive dates from — a single row for the class as a whole, sorted
|
||||
* by when the term starts and labelled with `schedule` text
|
||||
* ({@see Offering::scheduleLabel()}) in place of a time.
|
||||
*
|
||||
* A row's `schedule` is the tell: non-null means "this is a class, described in
|
||||
* words, not a session at a known time", and every renderer shows that text
|
||||
* instead of a date and time.
|
||||
*/
|
||||
class SessionSchedule {
|
||||
|
||||
/**
|
||||
* Marks a row as a group-class session rather than a one-to-one lesson.
|
||||
* Callers use it to withhold the per-lesson actions (cancel, detail links)
|
||||
* that only mean something for a booked slot.
|
||||
*/
|
||||
public const KIND = 'group_class';
|
||||
|
||||
public function __construct(
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upcoming sessions of every class a student is enrolled in, soonest first.
|
||||
*
|
||||
* A withdrawn (cancelled) enrolment contributes nothing; a completed one is
|
||||
* kept, since "completed" describes the enrolment's billing state and says
|
||||
* nothing about whether the class has met yet.
|
||||
*
|
||||
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||
*/
|
||||
public function upcomingForStudent( int $studentId, string $now ): array {
|
||||
$rows = [];
|
||||
|
||||
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
|
||||
if ( Enrollment::STATUS_CANCELLED === $enrollment->status ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
if ( null === $offering ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = array_merge(
|
||||
$rows,
|
||||
$this->rowsFor( $offering, $now, (int) $enrollment->id, $enrollment->instructorId, $enrollment->status )
|
||||
);
|
||||
}
|
||||
|
||||
return self::sortedByStart( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming sessions of every active group class an instructor teaches,
|
||||
* soonest first — one row per session, not per enrolled student. Enrolments
|
||||
* are not consulted at all: a class the instructor has to turn up and teach
|
||||
* belongs on their schedule whether or not anyone has signed up yet.
|
||||
*
|
||||
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||
*/
|
||||
public function upcomingForInstructor( int $instructorId, string $now ): array {
|
||||
$rows = [];
|
||||
|
||||
$classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true );
|
||||
|
||||
foreach ( $classes as $offering ) {
|
||||
$rows = array_merge( $rows, $this->rowsFor( $offering, $now, 0, $instructorId, Enrollment::STATUS_ACTIVE ) );
|
||||
}
|
||||
|
||||
return self::sortedByStart( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* One class's contribution to an upcoming list: its remaining dated sessions,
|
||||
* or — when it has no time to derive dates from — a single row describing the
|
||||
* class in words. Empty only when the class has demonstrably finished.
|
||||
*
|
||||
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||
*/
|
||||
private function rowsFor( Offering $offering, string $now, int $enrollmentId, int $instructorId, string $status ): array {
|
||||
$base = [
|
||||
'enrollment_id' => $enrollmentId,
|
||||
'offering_id' => (int) $offering->id,
|
||||
'offering_title' => $offering->title,
|
||||
'instructor_id' => $instructorId,
|
||||
'status' => $status,
|
||||
'duration_minutes' => $offering->durationMinutes,
|
||||
];
|
||||
|
||||
$starts = $offering->sessionStarts();
|
||||
|
||||
// Dated: the class says exactly when it meets, so list what is left of it
|
||||
// — and nothing at all once the term is over.
|
||||
if ( [] !== $starts ) {
|
||||
$rows = [];
|
||||
|
||||
foreach ( $starts as $start ) {
|
||||
if ( $start < $now ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $base + [
|
||||
'start_dt' => $start,
|
||||
// Left open when no duration is set. Knowing a class starts at
|
||||
// four o'clock is worth showing even without knowing when it
|
||||
// ends; guessing an end time is not.
|
||||
'end_dt' => $this->endOf( $offering, $start ),
|
||||
'schedule' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// Undated: no class time, so there is nothing to put on a clock. The class
|
||||
// still gets a row — it is enrolled in and running — described by the
|
||||
// studio's own schedule note or its term dates.
|
||||
if ( ! $this->isStillRunning( $offering, $now ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$base + [
|
||||
// A sort key, not a claim about when the class meets: a class yet to
|
||||
// start sorts to its first day, one already under way to right now.
|
||||
// `schedule` is what any renderer actually shows.
|
||||
'start_dt' => $this->sortKeyFor( $offering, $now ),
|
||||
'end_dt' => '',
|
||||
'schedule' => $offering->scheduleLabel(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* When a session that starts at `$start` finishes, or an empty string when the
|
||||
* class has no duration to close it off with.
|
||||
*/
|
||||
private function endOf( Offering $offering, string $start ): string {
|
||||
if ( null === $offering->durationMinutes || $offering->durationMinutes <= 0 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ( new \DateTimeImmutable( $start ) )
|
||||
->add( new \DateInterval( 'PT' . $offering->durationMinutes . 'M' ) )
|
||||
->format( 'Y-m-d H:i:s' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an undated class still has life in it: its last day has not passed,
|
||||
* or it has no dates at all (in which case nothing says it has ended, and
|
||||
* dropping it would be the very disappearance this class exists to prevent).
|
||||
*/
|
||||
private function isStillRunning( Offering $offering, string $now ): bool {
|
||||
$lastDay = $offering->lastClassDay();
|
||||
|
||||
return null === $lastDay || $lastDay >= substr( $now, 0, 10 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an undated class sits in a list ordered by time: at its first day when
|
||||
* that is still ahead, otherwise at `$now`, so a term already under way reads
|
||||
* as current rather than as ancient history.
|
||||
*/
|
||||
private function sortKeyFor( Offering $offering, string $now ): string {
|
||||
if ( null === $offering->termStart ) {
|
||||
return $now;
|
||||
}
|
||||
|
||||
$firstDay = $offering->termStart . ' 00:00:00';
|
||||
|
||||
return $firstDay > $now ? $firstDay : $now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soonest session first, so classes from separate enrolments interleave by
|
||||
* date rather than arriving grouped by class.
|
||||
*
|
||||
* @param list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}> $rows
|
||||
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||
*/
|
||||
private static function sortedByStart( array $rows ): array {
|
||||
usort( $rows, static fn( array $a, array $b ): int => strcmp( $a['start_dt'], $b['start_dt'] ) );
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
/**
|
||||
* Keeps child accounts unusable as logins. A child holds the `us_student` role
|
||||
* so every `student_id` lookup in the schema keeps working, but nobody is ever
|
||||
* given its credentials — this closes the door the role would otherwise leave
|
||||
* open:
|
||||
*
|
||||
* - authentication is refused outright, and
|
||||
* - the booking capability is withheld, so nothing that reaches a capability
|
||||
* check on a child's own session (there should be none) can book as them.
|
||||
*
|
||||
* Both key off the `us_child` meta, so ordinary students are untouched.
|
||||
*/
|
||||
class ChildLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockChildLogin' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBooking' ], 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse authentication for a child account. Runs after password
|
||||
* verification, so it holds even if a password were somehow set on one.
|
||||
*
|
||||
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
public function blockChildLogin( $user ) {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
return new \WP_Error(
|
||||
'us_child_account',
|
||||
esc_html__( 'This is a managed student account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the booking capability from a child account, so the only route to a
|
||||
* lesson in their name is their guardian's authorised booking.
|
||||
*
|
||||
* @param array<string, bool> $allcaps All capabilities currently held.
|
||||
* @param array<int, string> $caps Required capabilities (unused).
|
||||
* @param array<int, mixed> $args Callback args (unused).
|
||||
* @param mixed $user The user being checked (a WP_User in practice).
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
|
||||
}
|
||||
|
||||
return $allcaps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The guardian's "my family" screen (`[us_family]`): their own details, plus
|
||||
* list, add, edit and remove the children they book for.
|
||||
*
|
||||
* Submissions are processed on `template_redirect` — before any output — and
|
||||
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
||||
* same child twice.
|
||||
*/
|
||||
class FamilyPage {
|
||||
|
||||
/** Query flag carrying a completed action back to {@see render()}. */
|
||||
private const RESULT_ADDED = 'added';
|
||||
private const RESULT_UPDATED = 'updated';
|
||||
private const RESULT_REMOVED = 'removed';
|
||||
private const RESULT_SELF = 'self';
|
||||
|
||||
/**
|
||||
* Error from the most recent submission processed on `template_redirect`,
|
||||
* carried over to {@see render()} so it can be shown inline with the form.
|
||||
*/
|
||||
private string $submitError = '';
|
||||
|
||||
public function __construct(
|
||||
private GuardianService $guardians,
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Renders the family shortcode/block output.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
*/
|
||||
public function render( array $atts ): string {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||
|
||||
return sprintf(
|
||||
'<p>%s <a href="%s">%s</a>.</p>',
|
||||
esc_html__( 'Please', 'unsupervised-schedular' ),
|
||||
esc_url( $this->loginUrl( $loginPageId ) ),
|
||||
esc_html__( 'log in to manage your profile', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
$userId = get_current_user_id();
|
||||
|
||||
$self = $this->guardians->accountHolder( $userId );
|
||||
$children = $this->guardians->children( $userId );
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$error = $this->submitError;
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
||||
$result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) );
|
||||
$notice = $this->noticeFor( $result );
|
||||
|
||||
// Which child the "edit" link opened, if any — the row is swapped for an
|
||||
// editable form rather than every row carrying one.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked.
|
||||
$editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/family-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an add/edit/remove submission on `template_redirect`, before any
|
||||
* page output, then post/redirect/get back to the page. An error is stashed
|
||||
* for {@see render()} to show inline with the form.
|
||||
*/
|
||||
public function maybeHandleSubmit(): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) );
|
||||
|
||||
if ( '' === $action || ! is_user_logged_in() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! check_admin_referer( 'us_family' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = get_current_user_id();
|
||||
|
||||
$result = match ( $action ) {
|
||||
'add' => $this->handleAdd( $userId ),
|
||||
'edit' => $this->handleEdit( $userId ),
|
||||
'remove' => $this->handleRemove( $userId ),
|
||||
'self' => $this->handleSelf( $userId ),
|
||||
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
||||
};
|
||||
|
||||
if ( $result instanceof \WP_Error ) {
|
||||
$this->submitError = $result->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a child, then record their answers to the account-signup questions —
|
||||
* asked per child, since they describe the student rather than the account.
|
||||
*
|
||||
* Required answers are validated *before* the child is created, so a missing
|
||||
* one never leaves a nameless half-added child behind.
|
||||
*/
|
||||
private function handleAdd( int $guardianId ): string|\WP_Error {
|
||||
$name = $this->postString( 'child_name' );
|
||||
$birthYear = $this->postString( 'child_birth_year' );
|
||||
$relationship = $this->postString( 'child_relationship' );
|
||||
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
$missing = $this->firstMissingAnswer( $questions, $answers );
|
||||
if ( null !== $missing ) {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
$childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
return $childId;
|
||||
}
|
||||
|
||||
$this->recordAnswers( $questions, $answers, $childId );
|
||||
|
||||
return self::RESULT_ADDED;
|
||||
}
|
||||
|
||||
private function handleEdit( int $guardianId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
|
||||
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
|
||||
|
||||
return $error ?? self::RESULT_UPDATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the account holder's own details. The birth year is only asked of a
|
||||
* student, so it is the checkbox — not the browser — that decides whether one
|
||||
* is required; the field carries no `required` attribute, or a guardian who
|
||||
* books only for other people could never submit the form at all.
|
||||
*/
|
||||
private function handleSelf( int $userId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$isStudent = isset( $_POST['is_student'] );
|
||||
|
||||
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
|
||||
|
||||
return $error ?? self::RESULT_SELF;
|
||||
}
|
||||
|
||||
private function handleRemove( int $guardianId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
|
||||
$error = $this->guardians->removeChild( $guardianId, $childId );
|
||||
|
||||
return $error ?? self::RESULT_REMOVED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first required question left unanswered, as the error to show — or null
|
||||
* when every required question has a value.
|
||||
*
|
||||
* This screen only ever adds a student the guardian registers, so the
|
||||
* students' required-ness is the one that applies — the same rule the child
|
||||
* blocks on the signup form are held to.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
||||
foreach ( $questions as $question ) {
|
||||
if ( $question->isRequiredForChild() && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a child's answers to the account-signup questions. The answer is
|
||||
* recorded against the child, not the guardian, so a studio admin reading a
|
||||
* child's screen sees the information that describes them.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function recordAnswers( array $questions, array $answers, int $childId ): 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: $childId,
|
||||
studentId: $childId,
|
||||
answerValue: $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function submittedAnswers(): array {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sanitized text field from the submission. The caller has already verified
|
||||
* the nonce.
|
||||
*/
|
||||
private function postString( string $key ): string {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation to show for a completed action, or an empty string when
|
||||
* the flag is absent or unrecognised.
|
||||
*/
|
||||
private function noticeFor( string $result ): string {
|
||||
return match ( $result ) {
|
||||
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
|
||||
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
|
||||
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The current page's clean permalink, used as the post/redirect/get target so
|
||||
* the edit flag and any stale notice 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL the logged-out prompt sends visitors to: the chosen login page when one
|
||||
* is configured (and still exists), otherwise the WordPress login screen with
|
||||
* a redirect back to the current page.
|
||||
*/
|
||||
public function loginUrl( int $loginPageId ): string {
|
||||
if ( $loginPageId > 0 ) {
|
||||
$url = get_permalink( $loginPageId );
|
||||
|
||||
if ( is_string( $url ) ) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
$permalink = get_permalink();
|
||||
|
||||
return wp_login_url( false === $permalink ? '' : $permalink );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* One parent/guardian ↔ child link. The child is a real (login-less) WordPress
|
||||
* user, so `studentId` is a `wp_users` ID exactly like every other student id in
|
||||
* the schema — this row only records who books and pays on their behalf.
|
||||
*/
|
||||
class GuardianLink {
|
||||
|
||||
public function __construct(
|
||||
public readonly int $guardianId,
|
||||
public readonly int $studentId,
|
||||
public readonly string $relationship = '',
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
guardianId: Val::int( $row->guardian_id ),
|
||||
studentId: Val::int( $row->student_id ),
|
||||
relationship: Val::string( $row->relationship ?? '' ),
|
||||
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the link.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'guardian_id' => $this->guardianId,
|
||||
'student_id' => $this->studentId,
|
||||
'relationship' => $this->relationship,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
class GuardianRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_guardians';
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a child to a guardian. Returns 0 without inserting when the child
|
||||
* already has a guardian: v1 is one guardian per child, and the check lives
|
||||
* here so every caller (signup, the family screen, admin) gets it.
|
||||
*/
|
||||
public function insert( GuardianLink $link ): int {
|
||||
if ( null !== $this->findByStudent( $link->studentId ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $link->guardianId,
|
||||
'student_id' => $link->studentId,
|
||||
'relationship' => $link->relationship,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link naming this child's guardian, or null when they book for
|
||||
* themselves.
|
||||
*/
|
||||
public function findByStudent( int $studentId ): ?GuardianLink {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return $row ? GuardianLink::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every child linked to a guardian, oldest link first — the order they are
|
||||
* offered in the booking selector, so it stays stable as children are added.
|
||||
*
|
||||
* @return list<GuardianLink>
|
||||
*/
|
||||
public function findByGuardian( int $guardianId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( GuardianLink::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this exact guardian↔child pair is linked — the authorisation check
|
||||
* behind every "act for this student" boundary.
|
||||
*/
|
||||
public function isGuardianOf( int $guardianId, int $studentId ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$guardianId,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the link between a guardian and one of their children. Deleting the
|
||||
* child user itself is the caller's decision ({@see GuardianService::removeChild()});
|
||||
* this only unlinks.
|
||||
*/
|
||||
public function delete( int $guardianId, int $studentId ): bool {
|
||||
$deleted = $this->db->delete(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $guardianId,
|
||||
'student_id' => $studentId,
|
||||
],
|
||||
[ '%d', '%d' ]
|
||||
);
|
||||
|
||||
return (int) $deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many children a guardian has — enough to decide whether the booking
|
||||
* page needs a "who is this for?" selector at all.
|
||||
*/
|
||||
public function countChildren( int $guardianId ): int {
|
||||
$count = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COUNT(*) FROM %i WHERE guardian_id = %d',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Everything a guardian does on a child's behalf: creating the child's
|
||||
* login-less account, deciding who may act for whom, and resolving the payer and
|
||||
* contact behind a student id.
|
||||
*/
|
||||
class GuardianService {
|
||||
|
||||
/**
|
||||
* Marks a `wp_users` row as a child account: created by a guardian, holding
|
||||
* the student role so every `student_id` lookup keeps working, but with no
|
||||
* usable login. {@see ChildLoginGate} enforces the "no login" half.
|
||||
*/
|
||||
public const META_CHILD = 'us_child';
|
||||
|
||||
/** A child's birth year (`YYYY`), collected at signup and editable after. */
|
||||
public const META_BIRTH_YEAR = 'us_birth_year';
|
||||
|
||||
/**
|
||||
* The full date of birth this feature used to collect. Nothing writes it any
|
||||
* more: it is read once, to derive a birth year for a child who predates the
|
||||
* change, and cleared the moment that child's record is next saved. Kept
|
||||
* public so a site that wants to purge the old dates outright can find them.
|
||||
*/
|
||||
public const META_DOB = 'us_date_of_birth';
|
||||
|
||||
/**
|
||||
* Set on an account that registered **only** to book for other people, so it
|
||||
* is not offered as a student in its own right.
|
||||
*
|
||||
* Stored as the negative on purpose. Every account that existed before this
|
||||
* choice was offered is a bookable student, and absence of the flag has to
|
||||
* keep meaning exactly that — otherwise the picker would quietly stop
|
||||
* offering people themselves on upgrade.
|
||||
*/
|
||||
public const META_GUARDIAN_ONLY = 'us_guardian_only';
|
||||
|
||||
/**
|
||||
* The earliest birth year the form will accept. Old enough for any student a
|
||||
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
|
||||
* that would otherwise be stored as a plausible-looking year.
|
||||
*/
|
||||
private const MIN_BIRTH_YEAR = 1900;
|
||||
|
||||
/**
|
||||
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
||||
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
||||
* undeliverable — nothing about a child's account can ever be emailed to
|
||||
* somewhere real by mistake.
|
||||
*/
|
||||
private const CHILD_EMAIL_DOMAIN = 'child.invalid';
|
||||
|
||||
public function __construct(
|
||||
private GuardianRepository $guardians,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a login-less child account and link it to its guardian. The password
|
||||
* is random and discarded — it is never stored anywhere readable, emailed, or
|
||||
* shown — so the account cannot be signed into even if the gate were removed.
|
||||
*
|
||||
* Returns the new user ID, or a `WP_Error` when the name is blank, the birth
|
||||
* year is missing or unusable, or WordPress refuses the insert.
|
||||
*/
|
||||
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
|
||||
}
|
||||
|
||||
$email = $this->childEmail();
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => wp_generate_password( 24, true, true ),
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
'role' => RoleManager::STUDENT,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $userId ) ) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$userId = (int) $userId;
|
||||
|
||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||
|
||||
// A child is a student created by someone who is not staff, so the
|
||||
// registration gate holds it on `user_register` like any other unattributed
|
||||
// signup. There is nothing here to approve: the account is never signed in
|
||||
// to, and the guardian in front of us is the approval. Leaving the hold on
|
||||
// would put every child a family adds into the studio's review queue.
|
||||
RegistrationStatus::approve( $userId );
|
||||
|
||||
$this->setBirthYear( $userId, $birthYear );
|
||||
|
||||
$linkId = $this->guardians->insert(
|
||||
new GuardianLink(
|
||||
guardianId: $guardianId,
|
||||
studentId: $userId,
|
||||
relationship: trim( $relationship ),
|
||||
)
|
||||
);
|
||||
|
||||
// The child was just created, so it cannot already be linked — a failure
|
||||
// here means the insert itself failed, and leaving an unreachable orphan
|
||||
// user behind would be worse than reporting it.
|
||||
if ( $linkId <= 0 ) {
|
||||
$this->deleteUser( $userId );
|
||||
|
||||
return new \WP_Error( 'link_failed', __( 'Could not add this student. Please contact the studio.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a child and update their birth year. Refuses a student the caller
|
||||
* is not the guardian of, so the family screen cannot be turned into an
|
||||
* arbitrary user editor by posting someone else's id.
|
||||
*
|
||||
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
|
||||
*/
|
||||
public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
|
||||
}
|
||||
|
||||
$result = wp_update_user(
|
||||
[
|
||||
'ID' => $studentId,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setBirthYear( $studentId, $birthYear );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the account holder's own details from the profile screen: their
|
||||
* name, whether they are a student in their own right, and — when they are —
|
||||
* their birth year.
|
||||
*
|
||||
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
|
||||
* the form can ask the question the way a person would answer it and this is
|
||||
* the single place the sense is flipped.
|
||||
*
|
||||
* Returns null on success, mirroring {@see updateChild()}.
|
||||
*/
|
||||
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
|
||||
}
|
||||
|
||||
$result = wp_update_user(
|
||||
[
|
||||
'ID' => $userId,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setGuardianOnly( $userId, ! $isStudent );
|
||||
|
||||
// Only written when they are a student. Saying "I only book for other
|
||||
// people" is a statement about who books, not an instruction to forget a
|
||||
// year already on file — and someone who ticks the box back on the next
|
||||
// visit should find their own details as they left them.
|
||||
if ( $isStudent ) {
|
||||
$this->setBirthYear( $userId, $birthYear );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a child and delete their account. Refused once the child has any
|
||||
* lesson or enrolment history: their id is referenced by lessons, payments and
|
||||
* credits, and deleting the user would orphan all of it. A studio admin
|
||||
* handles those cases by hand.
|
||||
*
|
||||
* Returns null on success.
|
||||
*/
|
||||
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
||||
return new \WP_Error(
|
||||
'has_history',
|
||||
__( 'This student has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$this->guardians->delete( $guardianId, $studentId );
|
||||
$this->deleteUser( $studentId );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `$actorId` may book, cancel and pay as `$studentId` — true for
|
||||
* themselves, and for a guardian acting as one of their own children. This is
|
||||
* the authorisation boundary the REST endpoints and form handlers check before
|
||||
* honouring a submitted student id.
|
||||
*/
|
||||
public function canActFor( int $actorId, int $studentId ): bool {
|
||||
if ( $actorId <= 0 || $studentId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $actorId === $studentId || $this->guardians->isGuardianOf( $actorId, $studentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Who owes a student's charges: their guardian when they have one, otherwise
|
||||
* themselves. Payments, credits and the billing-method override all resolve
|
||||
* through this, so a family shares one balance and one billing setting.
|
||||
*/
|
||||
public function payerFor( int $studentId ): int {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
|
||||
return null !== $link ? $link->guardianId : $studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The student ids whose lessons `$userId` may see: their own plus every child
|
||||
* they are guardian for.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function householdIds( int $userId ): array {
|
||||
$ids = [ $userId ];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $userId ) as $link ) {
|
||||
$ids[] = $link->studentId;
|
||||
}
|
||||
|
||||
return array_values( array_unique( $ids ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a user may book or enrol for: **children first**, then
|
||||
* themselves. The order is the point — a guardian's normal case is booking for
|
||||
* a child, so the first option (and hence the default selection) is a child,
|
||||
* never the parent. Booking for a child by mistake is a correctable
|
||||
* inconvenience; silently billing a parent's account for a lesson meant for
|
||||
* their kid is the error worth designing out.
|
||||
*
|
||||
* The guardian is still offered, last, so a parent taking lessons alongside
|
||||
* their children can book for themselves from the same account — unless they
|
||||
* said at signup that they are not a student, in which case offering them is
|
||||
* an invitation to book a lesson nobody meant to buy.
|
||||
*
|
||||
* @return list<array{id: int, name: string, is_self: bool}>
|
||||
*/
|
||||
public function bookableStudents( int $userId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->children( $userId ) as $child ) {
|
||||
$out[] = [
|
||||
'id' => $child['id'],
|
||||
'name' => $child['name'],
|
||||
'is_self' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// A guardian-only account with nobody linked to it would otherwise get an
|
||||
// empty list and no way to book at all. Offering them themselves is the
|
||||
// lesser wrong: they can still correct the account from the profile page.
|
||||
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$self = get_userdata( $userId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $userId,
|
||||
'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ),
|
||||
'is_self' => true,
|
||||
];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account books only for other people. False for every account
|
||||
* that predates the choice — see {@see META_GUARDIAN_ONLY}.
|
||||
*/
|
||||
public static function isGuardianOnly( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record whether this account is a student in its own right. Clears the flag
|
||||
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
|
||||
* they are a student".
|
||||
*/
|
||||
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
|
||||
if ( $guardianOnly ) {
|
||||
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
|
||||
return;
|
||||
}
|
||||
|
||||
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
|
||||
}
|
||||
|
||||
/**
|
||||
* A guardian's children, in link order, with the details the family and admin
|
||||
* screens display.
|
||||
*
|
||||
* @return list<array{id: int, name: string, birth_year: string, relationship: string}>
|
||||
*/
|
||||
public function children( int $guardianId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $guardianId ) as $link ) {
|
||||
$user = get_userdata( $link->studentId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $link->studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||
'birth_year' => $this->birthYear( $link->studentId ),
|
||||
'relationship' => $link->relationship,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account holder's own details, as the profile screen's form needs them.
|
||||
* The counterpart to {@see children()} for the person reading the page.
|
||||
*
|
||||
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
|
||||
* {@see updateSelf()}, which reads it back the same way round.
|
||||
*
|
||||
* @return array{name: string, email: string, birth_year: string, is_student: bool}
|
||||
*/
|
||||
public function accountHolder( int $userId ): array {
|
||||
$user = get_userdata( $userId );
|
||||
|
||||
return [
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
'birth_year' => $this->birthYear( $userId ),
|
||||
'is_student' => ! self::isGuardianOnly( $userId ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The guardian behind a child, or null when the student books for themselves.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}|null
|
||||
*/
|
||||
public function guardianOf( int $studentId ): ?array {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
if ( null === $link ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = get_userdata( $link->guardianId );
|
||||
|
||||
return [
|
||||
'id' => $link->guardianId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->guardianId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to contact about a student: their guardian when they have one, otherwise
|
||||
* the student. What an instructor looking at a child's lesson actually needs —
|
||||
* a child's own address is an undeliverable placeholder.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}
|
||||
*/
|
||||
public function contactFor( int $studentId ): array {
|
||||
$guardian = $this->guardianOf( $studentId );
|
||||
if ( null !== $guardian ) {
|
||||
return $guardian;
|
||||
}
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return [
|
||||
'id' => $studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $studentId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's display name, or an empty string when the user is gone. Used
|
||||
* wherever a charge or lesson has to say whose it is.
|
||||
*/
|
||||
public function studentName( int $studentId ): string {
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return UserName::format( $user instanceof \WP_User ? $user : null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is a child account (created by a guardian, cannot sign in).
|
||||
*/
|
||||
public static function isChild( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_CHILD, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a child's user account. Split out so the front-end paths pull in the
|
||||
* admin user functions `wp_delete_user()` lives in — it is not loaded on the
|
||||
* front end, where the family screen runs.
|
||||
*/
|
||||
public function deleteUser( int $userId ): void {
|
||||
if ( ! function_exists( 'wp_delete_user' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
|
||||
wp_delete_user( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a student's birth year, or clear it when blank or out of range. Used
|
||||
* for a child added by their guardian and for an account holder who is a
|
||||
* student in their own right — the same fact about the same kind of person,
|
||||
* so the same meta key holds both.
|
||||
*
|
||||
* Either way the legacy full date of birth goes with it. That is what makes
|
||||
* the read fallback in {@see birthYear()} safe: without it, clearing the year
|
||||
* on a child who predates this change would leave the old date behind for the
|
||||
* fallback to resurrect on the very next read.
|
||||
*/
|
||||
public function setBirthYear( int $userId, string $birthYear ): void {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
|
||||
$year = self::normaliseBirthYear( $birthYear );
|
||||
|
||||
if ( 0 === $year ) {
|
||||
delete_user_meta( $userId, self::META_BIRTH_YEAR );
|
||||
return;
|
||||
}
|
||||
|
||||
update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
|
||||
}
|
||||
|
||||
/**
|
||||
* A submitted birth year as an integer, or 0 when it is blank, not a number,
|
||||
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
|
||||
* every time, so it is refused rather than stored.
|
||||
*
|
||||
* Public and static so the signup form can reject a bad year up front, before
|
||||
* it creates any users, without a second copy of the rule to keep in step.
|
||||
*/
|
||||
public static function normaliseBirthYear( string $birthYear ): int {
|
||||
$birthYear = trim( $birthYear );
|
||||
|
||||
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$year = (int) $birthYear;
|
||||
|
||||
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message shown when a birth year is missing or unusable. One phrasing,
|
||||
* shared by the signup form and the profile screen, so a guardian is told the
|
||||
* same thing whichever way they got there.
|
||||
*/
|
||||
public static function birthYearError(): string {
|
||||
return sprintf(
|
||||
/* translators: %d: the earliest birth year the form accepts. */
|
||||
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
|
||||
self::MIN_BIRTH_YEAR
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same message for the account holder's own birth year. Separate wording
|
||||
* because "each student" is nobody when the student in question is the person
|
||||
* reading it.
|
||||
*/
|
||||
public static function ownBirthYearError(): string {
|
||||
return sprintf(
|
||||
/* translators: %d: the earliest birth year the form accepts. */
|
||||
__( 'Please give your birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
|
||||
self::MIN_BIRTH_YEAR
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A child's birth year, or an empty string when none is recorded.
|
||||
*
|
||||
* Falls back to the year of the full date of birth this feature used to
|
||||
* collect, so a child added before the change still shows one. The fallback
|
||||
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
|
||||
* as the record is saved again.
|
||||
*/
|
||||
private function birthYear( int $userId ): string {
|
||||
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
|
||||
if ( '' !== $year ) {
|
||||
return $year;
|
||||
}
|
||||
|
||||
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
|
||||
|
||||
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* An unused placeholder address for a child's account. WordPress requires a
|
||||
* unique email per user, so the random suffix is retried against
|
||||
* `email_exists()` rather than assumed unique.
|
||||
*/
|
||||
private function childEmail(): string {
|
||||
do {
|
||||
$email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN;
|
||||
} while ( false !== email_exists( $email ) );
|
||||
|
||||
return strtolower( $email );
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
|
||||
class Installer {
|
||||
|
||||
@@ -12,10 +16,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 ) {
|
||||
@@ -37,5 +53,13 @@ class Installer {
|
||||
}
|
||||
|
||||
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
|
||||
|
||||
// Guardian accounts introduced "who pays" / "who agreed" alongside "who the
|
||||
// student is". Every row written before then had them one and the same, so
|
||||
// point the new columns at the student rather than leaving them 0 — the
|
||||
// balance and acceptance lookups key on them directly.
|
||||
( new PaymentRepository( $wpdb ) )->backfillPayerIds();
|
||||
( new CreditRepository( $wpdb ) )->backfillPayerIds();
|
||||
( new AcceptanceRepository( $wpdb ) )->backfillAcceptedBy();
|
||||
}
|
||||
}
|
||||
|
||||
+150
-22
@@ -20,12 +20,26 @@ class Offering {
|
||||
public const BILLING_ONE_TIME = 'one_time';
|
||||
public const BILLING_FULL_TERM = 'full_term';
|
||||
|
||||
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
|
||||
public const BILLING_WEEKLY = 'weekly';
|
||||
|
||||
/** Billed on the first of each month for every lesson that falls in the month. */
|
||||
public const BILLING_MONTHLY = 'monthly';
|
||||
|
||||
/**
|
||||
* All valid billing modes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/**
|
||||
* Billing modes whose payment is generated later by the daily billing scan
|
||||
* rather than taken at registration.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
|
||||
public const ACCESS_PUBLIC = 'public';
|
||||
@@ -40,6 +54,15 @@ class Offering {
|
||||
*/
|
||||
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,
|
||||
public readonly string $kind,
|
||||
@@ -54,6 +77,8 @@ class Offering {
|
||||
public readonly ?string $termStart = null,
|
||||
public readonly ?string $termEnd = null,
|
||||
public readonly ?string $classTime = null,
|
||||
public readonly ?string $enrollmentDeadline = null,
|
||||
public readonly ?string $withdrawalDeadline = null,
|
||||
public readonly ?string $scheduleNote = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?int $cancellationCutoffHours = null,
|
||||
@@ -70,6 +95,48 @@ class Offering {
|
||||
return self::ACCESS_INVITE_ONLY === $this->accessMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this offering's payment is deferred to the daily billing scan
|
||||
* (weekly / monthly) instead of being taken at registration.
|
||||
*/
|
||||
public function isScheduledBilling(): bool {
|
||||
return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* The last day on which a student may enrol in this group class. Defaults to
|
||||
* the first day of the class (`term_start`) when the instructor has not set an
|
||||
* explicit deadline; null only when the class has no dates at all.
|
||||
*/
|
||||
public function effectiveEnrollmentDeadline(): ?string {
|
||||
return $this->enrollmentDeadline ?? $this->termStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays
|
||||
* open through the end of the deadline day, so the first class is still
|
||||
* enrollable under the default deadline. A class with no deadline at all (no
|
||||
* dates configured) is always open.
|
||||
*/
|
||||
public function isEnrollmentOpen( string $today ): bool {
|
||||
$deadline = $this->effectiveEnrollmentDeadline();
|
||||
|
||||
return null === $deadline || $today <= $deadline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -108,22 +175,19 @@ class Offering {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The datetime each session of this group class starts, derived from the class
|
||||
* date(s) and the class time. A weekly class yields one per week from
|
||||
* `term_start` through `term_end`; a one-off class yields a single one.
|
||||
*
|
||||
* @return list<array{start: string, end: string}>
|
||||
* Deliberately does **not** need a duration: knowing *when* a class meets is a
|
||||
* separate question from knowing how long it runs, and a studio can quite
|
||||
* reasonably set the first without the second. Returns an empty list when
|
||||
* there is no date or no time, since neither can be invented.
|
||||
*
|
||||
* @return list<string> `Y-m-d H:i:s` starts, earliest first.
|
||||
*/
|
||||
public function sessionWindows(): array {
|
||||
if (
|
||||
null === $this->termStart
|
||||
|| null === $this->classTime
|
||||
|| null === $this->durationMinutes
|
||||
|| $this->durationMinutes <= 0
|
||||
) {
|
||||
public function sessionStarts(): array {
|
||||
if ( null === $this->termStart || null === $this->classTime ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -133,25 +197,85 @@ class Offering {
|
||||
}
|
||||
|
||||
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
|
||||
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||||
|
||||
$windows = [];
|
||||
$starts = [];
|
||||
$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' ),
|
||||
];
|
||||
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
|
||||
|
||||
$cursor = $cursor->modify( '+7 days' );
|
||||
$cursorDay = $cursor->format( 'Y-m-d' );
|
||||
}
|
||||
|
||||
return $windows;
|
||||
return $starts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete start/end datetimes of every session of this group class:
|
||||
* {@see sessionStarts()} closed off with the class duration. 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 —
|
||||
* callers that block availability or bill per session need both ends.
|
||||
*
|
||||
* @return list<array{start: string, end: string}>
|
||||
*/
|
||||
public function sessionWindows(): array {
|
||||
if ( null === $this->durationMinutes || $this->durationMinutes <= 0 ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||||
|
||||
return array_map(
|
||||
static fn( string $start ): array => [
|
||||
'start' => $start,
|
||||
'end' => ( new \DateTimeImmutable( $start ) )->add( $step )->format( 'Y-m-d H:i:s' ),
|
||||
],
|
||||
$this->sessionStarts()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The last day this class meets, or null when it has no dates at all.
|
||||
*/
|
||||
public function lastClassDay(): ?string {
|
||||
return $this->termEnd ?? $this->termStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-language wording for when this class meets, for the places that have
|
||||
* to say something about a class whose schedule cannot be resolved to dates.
|
||||
* Prefers the studio's own note ("Tuesdays 4:00pm") — that field exists
|
||||
* precisely so a class can describe its schedule without pinning it to a
|
||||
* time — then the term dates, and finally an honest admission that nothing
|
||||
* has been set.
|
||||
*/
|
||||
public function scheduleLabel(): string {
|
||||
$note = null !== $this->scheduleNote ? trim( $this->scheduleNote ) : '';
|
||||
if ( '' !== $note ) {
|
||||
return $note;
|
||||
}
|
||||
|
||||
if ( null === $this->termStart ) {
|
||||
return __( 'Schedule to be confirmed', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$start = (string) mysql2date( 'M j, Y', $this->termStart );
|
||||
|
||||
if ( null === $this->termEnd || $this->termEnd === $this->termStart ) {
|
||||
return $start;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: first class date, 2: last class date. */
|
||||
__( '%1$s – %2$s', 'unsupervised-schedular' ),
|
||||
$start,
|
||||
(string) mysql2date( 'M j, Y', $this->termEnd )
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
@@ -169,6 +293,8 @@ class Offering {
|
||||
termStart: Val::stringOrNull( $row->term_start ),
|
||||
termEnd: Val::stringOrNull( $row->term_end ),
|
||||
classTime: Val::stringOrNull( $row->class_time ?? null ),
|
||||
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
|
||||
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 ),
|
||||
@@ -203,6 +329,8 @@ class Offering {
|
||||
'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,
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
@@ -11,6 +12,7 @@ class OfferingController {
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private ClassSlotReconciler $reconciler,
|
||||
private AccessSettings $access = new AccessSettings(),
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -137,17 +139,28 @@ class OfferingController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered instructors offered in the assignment select, by display name.
|
||||
* Instructors offered in the assignment select, by display name.
|
||||
*
|
||||
* Includes everyone holding the `us_instructor` role plus, when the site owner
|
||||
* has left administrators acting as instructors (the default single-account
|
||||
* setup), WordPress administrators — who teach through the dynamic capability
|
||||
* grant rather than the role. Without them a solo studio owner running the
|
||||
* business from an admin account would find no one to assign a class to.
|
||||
*
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
private function instructorOptions(): array {
|
||||
$roles = [ RoleManager::INSTRUCTOR ];
|
||||
if ( $this->access->adminsAreInstructors() ) {
|
||||
$roles[] = 'administrator';
|
||||
}
|
||||
|
||||
$users = array_filter(
|
||||
get_users(
|
||||
[
|
||||
'role' => RoleManager::INSTRUCTOR,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
'role__in' => $roles,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
),
|
||||
static fn( mixed $u ): bool => $u instanceof \WP_User
|
||||
@@ -184,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;
|
||||
@@ -209,6 +233,15 @@ class OfferingController {
|
||||
|
||||
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
|
||||
|
||||
// A blank (or invalid) deadline means "use the default" — the first class
|
||||
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
|
||||
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
|
||||
|
||||
// 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: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
|
||||
kind: $kind,
|
||||
@@ -223,8 +256,10 @@ class OfferingController {
|
||||
termStart: $termStart,
|
||||
termEnd: $termEnd,
|
||||
classTime: $classTime,
|
||||
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'] ?? '' ) ) ) ),
|
||||
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'] ),
|
||||
|
||||
@@ -28,7 +28,7 @@ class OfferingEndpoint {
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [ $this, 'index' ],
|
||||
'permission_callback' => [ $this, 'canBook' ],
|
||||
'permission_callback' => [ $this, 'canRead' ],
|
||||
'args' => [
|
||||
'instructor_id' => [
|
||||
'type' => 'integer',
|
||||
@@ -148,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,
|
||||
@@ -161,8 +169,9 @@ 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' ),
|
||||
@@ -195,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,
|
||||
@@ -208,8 +226,9 @@ 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,
|
||||
@@ -243,12 +262,16 @@ class OfferingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading the offerings catalogue is only needed by the logged-in student
|
||||
* booking flow, so it requires the same capability as booking — there is no
|
||||
* anonymous consumer.
|
||||
* Reading the offerings catalogue has no anonymous consumer, so it stays
|
||||
* behind a login. Students reach it through the booking flow, and studio
|
||||
* admins and instructors reach it from the block editor's group-class
|
||||
* pickers — an administrator holds `manage_offerings` but not
|
||||
* `book_lesson`, so both capabilities open the listing.
|
||||
*/
|
||||
public function canBook(): bool {
|
||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
|
||||
public function canRead(): bool {
|
||||
return is_user_logged_in()
|
||||
&& ( current_user_can( RoleManager::CAP_BOOK_LESSON )
|
||||
|| current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +287,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 ) );
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ class OfferingRepository {
|
||||
/**
|
||||
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
|
||||
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
|
||||
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
|
||||
* 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', '%s', '%d', '%s', '%d' ];
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
|
||||
|
||||
public function insert( Offering $offering ): int {
|
||||
$this->db->insert(
|
||||
@@ -61,6 +62,8 @@ class OfferingRepository {
|
||||
'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,
|
||||
|
||||
@@ -7,8 +7,8 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Resolves the billing method for a student: a per-student override if set,
|
||||
* otherwise the studio default — card when Stripe is configured, e-transfer when
|
||||
* it is not.
|
||||
* otherwise the studio default chosen on Studio Settings — which itself falls
|
||||
* back to e-transfer whenever Stripe is not configured.
|
||||
*/
|
||||
class BillingMethodResolver {
|
||||
|
||||
@@ -27,10 +27,17 @@ class BillingMethodResolver {
|
||||
|
||||
/**
|
||||
* The studio default when a student has no explicit override.
|
||||
*
|
||||
* Card is only ever the default when the studio asked for it *and* Stripe is
|
||||
* configured; without keys there is nothing to charge a card with. A studio
|
||||
* that sets the default to e-transfer keeps every student on e-transfer even
|
||||
* with Stripe live, so card billing can be proven on a few students — each
|
||||
* given a per-student override — before the whole studio moves over.
|
||||
*/
|
||||
public function defaultMethod(): string {
|
||||
return $this->settings->isStripeConfigured()
|
||||
? Payment::METHOD_CARD
|
||||
: Payment::METHOD_ETRANSFER;
|
||||
return Payment::METHOD_CARD === $this->settings->defaultPaymentMethod()
|
||||
&& $this->settings->isStripeConfigured()
|
||||
? Payment::METHOD_CARD
|
||||
: Payment::METHOD_ETRANSFER;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?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,
|
||||
/**
|
||||
* The account holding this balance — a child's guardian, or 0 meaning
|
||||
* "the student themselves". A family's credits all sit on the guardian,
|
||||
* so one child's cancellation can settle a sibling's charge.
|
||||
*/
|
||||
public readonly int $payerId = 0,
|
||||
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 ),
|
||||
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose balance this credit sits in: the recorded payer, falling back to the
|
||||
* student. Callers go through here rather than reading `payerId`, so the `0`
|
||||
* default of a pre-guardian credit never leaks out as a user id.
|
||||
*/
|
||||
public function payerOrStudent(): int {
|
||||
return $this->payerId > 0 ? $this->payerId : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the credit.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'student_id' => $this->studentId,
|
||||
'payer_id' => $this->payerOrStudent(),
|
||||
'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,167 @@
|
||||
<?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,
|
||||
'payer_id' => $credit->payerOrStudent(),
|
||||
'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', '%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 payer's total unused credit balance (sum of the remaining amounts of every
|
||||
* still-available credit). Keyed on the payer, so a guardian's balance covers
|
||||
* credits earned by any of their children — one family, one balance.
|
||||
*/
|
||||
public function availableBalance( int $payerId ): float {
|
||||
$total = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE payer_id = %d AND status = %s',
|
||||
$this->table,
|
||||
$payerId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
|
||||
return round( (float) $total, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A payer's still-available credits, oldest first — the FIFO order they are
|
||||
* consumed in.
|
||||
*
|
||||
* @return list<Credit>
|
||||
*/
|
||||
public function findAvailableByPayer( int $payerId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE payer_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$payerId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Credit::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credit earned by a student, newest first — the admin history on their
|
||||
* own screen. Unlike the balance this is keyed on the student, so a child's
|
||||
* screen shows the credits their cancellations produced even though the
|
||||
* balance itself sits with their guardian.
|
||||
*
|
||||
* @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 ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `payer_id` on credits written before guardian accounts existed,
|
||||
* where the student was always the payer. Run once from the installer so the
|
||||
* payer-keyed balance queries see those rows.
|
||||
*/
|
||||
public function backfillPayerIds(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw down a payer'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 $payerId, float $amount ): void {
|
||||
$remaining = round( $amount, 2 );
|
||||
if ( $remaining <= 0.0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $this->findAvailableByPayer( $payerId ) 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,22 @@ class Payment {
|
||||
public readonly string $registrationType,
|
||||
public readonly int $registrationId,
|
||||
public readonly float $amount,
|
||||
/**
|
||||
* The account that owes this charge — a child's guardian, or 0 meaning
|
||||
* "the student themselves". Zero rather than a copy of `studentId` so
|
||||
* every payment written before guardian accounts existed reads back with
|
||||
* its original meaning without a data migration.
|
||||
*/
|
||||
public readonly int $payerId = 0,
|
||||
public readonly string $currency = 'CAD',
|
||||
public readonly string $method = self::METHOD_ETRANSFER,
|
||||
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,
|
||||
@@ -60,11 +71,16 @@ class Payment {
|
||||
registrationType: Val::string( $row->registration_type ),
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
amount: Val::float( $row->amount ),
|
||||
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||
currency: Val::string( $row->currency ),
|
||||
method: Val::string( $row->method ),
|
||||
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 +95,34 @@ class Payment {
|
||||
return self::STATUS_PAID === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who actually owes this charge: the recorded payer, falling back to the
|
||||
* student. Every caller that needs a person to bill, receipt or credit goes
|
||||
* through here rather than reading `payerId` directly, so the `0` default
|
||||
* never leaks out as a user id.
|
||||
*/
|
||||
public function payerOrStudent(): int {
|
||||
return $this->payerId > 0 ? $this->payerId : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone other than the student is paying — a guardian. Drives the
|
||||
* "paid by" line on admin screens, which is noise when they are the same
|
||||
* person.
|
||||
*/
|
||||
public function hasSeparatePayer(): bool {
|
||||
return $this->payerId > 0 && $this->payerId !== $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +130,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.
|
||||
@@ -109,6 +161,7 @@ class Payment {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'student_id' => $this->studentId,
|
||||
'payer_id' => $this->payerOrStudent(),
|
||||
'instructor_id' => $this->instructorId,
|
||||
'registration_type' => $this->registrationType,
|
||||
'etransfer_email' => $this->etransferEmail,
|
||||
@@ -117,9 +170,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 {
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
return [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
},
|
||||
$this->payments->findPending()
|
||||
);
|
||||
$groups = $this->groupPending( $this->payments->findPending() );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group pending payments by their shared notice batch, so payments the daily
|
||||
* scan emailed a student together (and which a single lump-sum e-transfer
|
||||
* covers) are shown as one group with a combined total. Payments with no batch
|
||||
* — legacy at-registration e-transfers — are each their own single-item group.
|
||||
*
|
||||
* @param list<Payment> $pending
|
||||
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
|
||||
*/
|
||||
private function groupPending( array $pending ): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ( $pending as $payment ) {
|
||||
$batch = (string) $payment->noticeBatch;
|
||||
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
|
||||
|
||||
if ( ! isset( $groups[ $key ] ) ) {
|
||||
$groups[ $key ] = [
|
||||
'reference' => $batch,
|
||||
'currency' => $payment->currency,
|
||||
'total_raw' => 0.0,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
// Show what the student still owes — the amount less any account credit
|
||||
// already applied to this payment.
|
||||
$groups[ $key ]['total_raw'] += $payment->netDue();
|
||||
$groups[ $key ]['rows'][] = [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->netDue(), 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( array $group ): array => [
|
||||
'reference' => $group['reference'],
|
||||
'is_group' => count( $group['rows'] ) > 1,
|
||||
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
|
||||
'rows' => $group['rows'],
|
||||
],
|
||||
$groups
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class PaymentRepository {
|
||||
$this->table,
|
||||
[
|
||||
'student_id' => $payment->studentId,
|
||||
'payer_id' => $payment->payerOrStudent(),
|
||||
'instructor_id' => $payment->instructorId,
|
||||
'registration_type' => $payment->registrationType,
|
||||
'registration_id' => $payment->registrationId,
|
||||
@@ -25,6 +26,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,12 +37,24 @@ 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', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `payer_id` on payments written before guardian accounts existed,
|
||||
* where the student was always the payer. Run once from the installer.
|
||||
*/
|
||||
public function backfillPayerIds(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
|
||||
* can later reconcile the charge back to this row.
|
||||
@@ -74,6 +91,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 +152,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(
|
||||
|
||||
+194
-12
@@ -21,6 +21,7 @@ class PaymentService {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private StudioSettings $settings,
|
||||
private StripeGateway $stripe,
|
||||
private CreditRepository $credits,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,14 +30,23 @@ 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.
|
||||
*
|
||||
* `$payerId` is who owes it — a child's guardian, or 0 (the default) when the
|
||||
* student pays for themselves. The billing method resolves against the payer,
|
||||
* so comping or card-billing a family is one setting on the guardian.
|
||||
*/
|
||||
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, int $payerId = 0 ): ?Payment {
|
||||
if ( $amount <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$method = $this->resolver->resolve( $studentId );
|
||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||
$payerId = $payerId > 0 ? $payerId : $studentId;
|
||||
$method = $this->resolver->resolve( $payerId );
|
||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||
|
||||
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
|
||||
? $offeringEtransferEmail
|
||||
@@ -53,11 +63,14 @@ class PaymentService {
|
||||
registrationType: $type,
|
||||
registrationId: $registrationId,
|
||||
amount: $amount,
|
||||
payerId: $payerId,
|
||||
currency: $currency,
|
||||
method: $method,
|
||||
status: $status,
|
||||
taxRate: $taxRate,
|
||||
taxAmount: $taxAmount,
|
||||
dueDate: $dueDate,
|
||||
periodKey: $periodKey,
|
||||
etransferEmail: $etransferEmail,
|
||||
)
|
||||
);
|
||||
@@ -65,12 +78,34 @@ class PaymentService {
|
||||
$this->linkPayment( $type, $registrationId, $id );
|
||||
|
||||
if ( Payment::STATUS_PAID === $status ) {
|
||||
$this->finalizePaid( $id, $type, $registrationId, $studentId );
|
||||
// The receipt goes to whoever paid, which for a child's lesson is the
|
||||
// guardian — a child's own address is an undeliverable placeholder.
|
||||
$this->finalizePaid( $id, $type, $registrationId, $payerId );
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -84,7 +119,7 @@ class PaymentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -92,7 +127,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 +138,147 @@ 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;
|
||||
}
|
||||
|
||||
// The credit records the child it was earned for, but the balance itself
|
||||
// lands on whoever paid — so a family's credits pool on the guardian and
|
||||
// one child's cancellation can settle a sibling's next charge.
|
||||
$id = $this->credits->insert(
|
||||
new Credit(
|
||||
studentId: $payment->studentId,
|
||||
amount: $share,
|
||||
remaining: $share,
|
||||
payerId: $payment->payerOrStudent(),
|
||||
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 payer'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 payer's notice.
|
||||
*
|
||||
* Keyed on the payer, so a guardian's balance settles charges raised against
|
||||
* any of their children — the payments passed in may name several students.
|
||||
*
|
||||
* @param list<Payment> $payments
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function applyCredits( int $payerId, array $payments ): array {
|
||||
$balance = $this->credits->availableBalance( $payerId );
|
||||
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( $payerId, $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
|
||||
@@ -113,11 +287,19 @@ class PaymentService {
|
||||
* needs no further action. Returns null when the registration has no payment,
|
||||
* the caller does not own it, or Stripe could not create the intent.
|
||||
*
|
||||
* `$userId` is the caller: either the student the registration is for, or the
|
||||
* guardian who owes it — anyone else gets null rather than a payment step for
|
||||
* a charge that is not theirs.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function createIntent( string $type, int $registrationId, int $studentId ): ?array {
|
||||
public function createIntent( string $type, int $registrationId, int $userId ): ?array {
|
||||
$payment = $this->payments->findByRegistration( $type, $registrationId );
|
||||
if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) {
|
||||
if ( null === $payment || null === $payment->id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( $payment->studentId !== $userId && $payment->payerOrStudent() !== $userId ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -175,7 +357,7 @@ class PaymentService {
|
||||
}
|
||||
|
||||
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
|
||||
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
||||
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
|
||||
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
|
||||
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
|
||||
}
|
||||
@@ -183,12 +365,12 @@ class PaymentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
|
||||
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $payerId ): void {
|
||||
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
|
||||
$this->confirmRegistration( $type, $registrationId );
|
||||
|
||||
$paid = $this->payments->findById( $paymentId );
|
||||
$user = get_userdata( $studentId );
|
||||
$user = get_userdata( $payerId );
|
||||
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
|
||||
$this->payments->markReceiptSent( $paymentId );
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<?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\Guardian\GuardianService;
|
||||
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 payer 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,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
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 *payer*, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a payer billed for several
|
||||
// lessons on one day is emailed once — never once per lesson, and a guardian
|
||||
// gets one notice covering every child rather than one per child. Each entry
|
||||
// keeps the created payment and its label; credits are applied across the
|
||||
// whole bucket before the notice is built, so the family'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.
|
||||
*
|
||||
* A monthly group class is priced **per month**, not per session: the fee is
|
||||
* charged once for the month however many times the class meets in it. This is
|
||||
* what the student is quoted and agrees to on the way in ("40.00 CAD monthly"),
|
||||
* and it is the one place the monthly rule differs from private lessons, whose
|
||||
* per-lesson fee is multiplied by the lessons that fall in the month.
|
||||
*
|
||||
* @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. The count does not
|
||||
// price the month — it names it on the student's notice ("3 sessions").
|
||||
$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,
|
||||
$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 payer'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.
|
||||
*
|
||||
* The charge is bucketed against whoever owes it, so a guardian's notice covers
|
||||
* all their children; the label names the child when that differs from the
|
||||
* payer, or a parent cannot tell whose lesson each line is.
|
||||
*
|
||||
* @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 {
|
||||
$payerId = $this->guardians->payerFor( $studentId );
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey, $payerId );
|
||||
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$buckets[ $payerId ][] = [
|
||||
'payment' => $payment,
|
||||
'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ),
|
||||
];
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix a notice line with the student it is for — "Ada: Piano Lesson —
|
||||
* Mar 3, 2026" — used only when the payer is not the student.
|
||||
*/
|
||||
private function labelFor( int $studentId, string $label ): string {
|
||||
$name = $this->guardians->studentName( $studentId );
|
||||
|
||||
return '' === $name ? $label : $name . ': ' . $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each payer, 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 $payerId => $entries ) {
|
||||
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
|
||||
$applied = $this->payments->applyCredits( $payerId, $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( $payerId );
|
||||
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';
|
||||
|
||||
/**
|
||||
* The studio-wide default billing method for students with no per-student
|
||||
* override. Card is the default; setting it to e-transfer holds every student
|
||||
* on e-transfer even once Stripe is live, so card billing can be trialled on a
|
||||
* few students before the whole studio moves over.
|
||||
*/
|
||||
public const OPT_DEFAULT_PAYMENT_METHOD = 'us_default_payment_method';
|
||||
|
||||
/**
|
||||
* Studio-default cancellation cutoff, stored in hours. A student may not
|
||||
* cancel a lesson once it starts within this many hours. Displayed to the
|
||||
@@ -56,6 +64,17 @@ class StudioSettings {
|
||||
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio-default billing method: `card` or `etransfer`. A card default
|
||||
* still degrades to e-transfer while Stripe is unconfigured — see
|
||||
* BillingMethodResolver, which owns that fallback.
|
||||
*/
|
||||
public function defaultPaymentMethod(): string {
|
||||
return Payment::METHOD_ETRANSFER === get_option( self::OPT_DEFAULT_PAYMENT_METHOD, Payment::METHOD_CARD )
|
||||
? Payment::METHOD_ETRANSFER
|
||||
: Payment::METHOD_CARD;
|
||||
}
|
||||
|
||||
public function currency(): string {
|
||||
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
|
||||
|
||||
@@ -112,13 +131,32 @@ class StudioSettings {
|
||||
return self::MODE_SELF_APPROVAL === $this->registrationMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every Stripe credential, returning the studio to e-transfer billing.
|
||||
* The mode drops back to `test` so a later re-configuration cannot go live by
|
||||
* inheriting the old setting.
|
||||
*/
|
||||
public function clearStripeConfig(): void {
|
||||
delete_option( self::OPT_PUBLISHABLE );
|
||||
delete_option( self::OPT_SECRET );
|
||||
delete_option( self::OPT_WEBHOOK_SECRET );
|
||||
delete_option( self::OPT_MODE );
|
||||
}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_settings_action' ) ) {
|
||||
$this->save();
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
|
||||
if ( 'clear_stripe' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
|
||||
$this->clearStripeConfig();
|
||||
$notice = __( 'Stripe configuration cleared. New registrations default to e-transfer until Stripe is set up again.', 'unsupervised-schedular' );
|
||||
} else {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
$publishableKey = $this->publishableKey();
|
||||
@@ -133,6 +171,10 @@ class StudioSettings {
|
||||
$etransferEmail = $this->etransferEmail();
|
||||
$hstRate = $this->hstRate();
|
||||
$stripeConfigured = $this->isStripeConfigured();
|
||||
$defaultMethod = $this->defaultPaymentMethod();
|
||||
// Offer the clear button whenever any Stripe value lingers, not only when
|
||||
// the pair of keys makes Stripe fully usable.
|
||||
$stripeAnySet = '' !== $publishableKey || $secretKeySet || $webhookSecretSet;
|
||||
$openRegistration = $this->openRegistrationEnabled();
|
||||
// Stored in hours, surfaced to the admin in whole days.
|
||||
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
|
||||
@@ -158,6 +200,13 @@ class StudioSettings {
|
||||
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
|
||||
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) );
|
||||
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
|
||||
// Anything but an explicit e-transfer choice means card, so a mangled or
|
||||
// missing field can never silently disable card billing studio-wide.
|
||||
$defaultMethod = sanitize_key( Val::string( wp_unslash( $_POST['default_payment_method'] ?? Payment::METHOD_CARD ) ) );
|
||||
update_option(
|
||||
self::OPT_DEFAULT_PAYMENT_METHOD,
|
||||
Payment::METHOD_ETRANSFER === $defaultMethod ? Payment::METHOD_ETRANSFER : Payment::METHOD_CARD
|
||||
);
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
|
||||
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
|
||||
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
|
||||
|
||||
+57
-12
@@ -3,24 +3,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
|
||||
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
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\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Guardian\ChildLoginGate;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
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;
|
||||
@@ -48,10 +59,29 @@ class Plugin {
|
||||
( new Installer() )->run();
|
||||
}
|
||||
|
||||
$availability = new AvailabilityRepository( $wpdb );
|
||||
$bookings = new BookingRepository( $wpdb );
|
||||
$offerings = new OfferingRepository( $wpdb );
|
||||
$questions = new QuestionRepository( $wpdb );
|
||||
$availability = new AvailabilityRepository( $wpdb );
|
||||
$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' );
|
||||
}
|
||||
|
||||
// One-time backfill of us_questions.is_required_child, which dbDelta adds
|
||||
// defaulting to 0 — leaving every question that *was* required no longer
|
||||
// required of the students a guardian registers. Runs after the version
|
||||
// gate above, so the column it writes to exists. Guarded so a question
|
||||
// later made optional for students stays that way.
|
||||
if ( '1' !== get_option( 'us_questions_child_required_backfilled', '' ) && $questions->backfillChildRequired() ) {
|
||||
update_option( 'us_questions_child_required_backfilled', '1' );
|
||||
}
|
||||
|
||||
$answers = new AnswerRepository( $wpdb );
|
||||
$policies = new PolicyRepository( $wpdb );
|
||||
$policyVersions = new PolicyVersionRepository( $wpdb );
|
||||
@@ -62,28 +92,43 @@ class Plugin {
|
||||
$groupAccess = new GroupAccessRepository( $wpdb );
|
||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||
|
||||
$guardianRepo = new GuardianRepository( $wpdb );
|
||||
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
|
||||
|
||||
$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 booking core is shared by the REST endpoint students book through and
|
||||
// the admin form staff book on their behalf with.
|
||||
$lessonBooker = new LessonBooker( $availability, $bookings, $offerings, $paymentService, $guardians );
|
||||
|
||||
// The shortcode and block wrappers share the same page objects so
|
||||
// front-end output is identical whichever way a page embeds them.
|
||||
$registrationMailer = new RegistrationMailer();
|
||||
|
||||
$bookingPage = new BookingPage();
|
||||
$bookingPage = new BookingPage( $guardians );
|
||||
$loginPage = new LoginPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
||||
$groupClassPage = new GroupClassPage( $guardians );
|
||||
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||
$accountPage = new AccountPage();
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->register();
|
||||
( new ChildLoginGate() )->register();
|
||||
( new StudentAdminGuard() )->register();
|
||||
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
|
||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register();
|
||||
( new 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();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,16 @@ class AcceptanceRepository {
|
||||
[
|
||||
'policy_version_id' => $acceptance->policyVersionId,
|
||||
'student_id' => $acceptance->studentId,
|
||||
'accepted_by' => $acceptance->acceptorOrStudent(),
|
||||
'registration_type' => $acceptance->registrationType,
|
||||
'registration_id' => $acceptance->registrationId,
|
||||
'ip_address' => $acceptance->ipAddress,
|
||||
'collected_via' => $acceptance->collectedVia,
|
||||
'collected_note' => $acceptance->collectedNote,
|
||||
'recorded_by' => $acceptance->recordedBy,
|
||||
'accepted_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%s', '%s' ]
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -38,6 +42,19 @@ class AcceptanceRepository {
|
||||
return array_map( fn( PolicyAcceptance $a ): int => $this->insert( $a ), $acceptances );
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `accepted_by` on acceptances recorded before guardian accounts
|
||||
* existed, where the student always agreed for themselves. Run once from the
|
||||
* installer so the acceptor is a real user id on every row.
|
||||
*/
|
||||
public function backfillAcceptedBy(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET accepted_by = student_id WHERE accepted_by = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all acceptances attached to a registration (lesson or enrolment).
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,27 @@ class PolicyAcceptance {
|
||||
public readonly int $studentId,
|
||||
public readonly string $registrationType,
|
||||
public readonly int $registrationId,
|
||||
/**
|
||||
* Who actually clicked "I agree" — a child's guardian, or 0 meaning the
|
||||
* student agreed for themselves. Zero rather than a copy of `studentId`
|
||||
* so every acceptance recorded before guardian accounts existed keeps its
|
||||
* original meaning.
|
||||
*/
|
||||
public readonly int $acceptedBy = 0,
|
||||
public readonly ?string $ipAddress = null,
|
||||
/**
|
||||
* How this acceptance reached the studio when it was not given online — see
|
||||
* {@see \Unsupervised\Schedular\Registration\IntakeProvenance}. Null is the
|
||||
* ordinary case: the student ticked the box themselves.
|
||||
*/
|
||||
public readonly ?string $collectedVia = null,
|
||||
public readonly ?string $collectedNote = null,
|
||||
/**
|
||||
* The staff member who typed it in, when somebody did. Distinct from
|
||||
* `acceptedBy`: the student still agreed, on paper or over the phone — this
|
||||
* is only who entered the record of it.
|
||||
*/
|
||||
public readonly int $recordedBy = 0,
|
||||
public readonly ?string $acceptedAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
@@ -35,12 +55,34 @@ class PolicyAcceptance {
|
||||
studentId: Val::int( $row->student_id ),
|
||||
registrationType: Val::string( $row->registration_type ),
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
|
||||
ipAddress: Val::stringOrNull( $row->ip_address ),
|
||||
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
|
||||
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
|
||||
recordedBy: Val::int( $row->recorded_by ?? 0 ),
|
||||
acceptedAt: Val::stringOrNull( $row->accepted_at ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this acceptance is legally attributable to: the recorded acceptor,
|
||||
* falling back to the student. Callers go through here so the `0` default of a
|
||||
* pre-guardian acceptance never leaks out as a user id.
|
||||
*/
|
||||
public function acceptorOrStudent(): int {
|
||||
return $this->acceptedBy > 0 ? $this->acceptedBy : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone other than the student agreed — a guardian accepting on a
|
||||
* child's behalf. Drives the "accepted by" line on the admin screen, which is
|
||||
* noise when they are the same person.
|
||||
*/
|
||||
public function acceptedOnBehalf(): bool {
|
||||
return $this->acceptedBy > 0 && $this->acceptedBy !== $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the acceptance.
|
||||
*
|
||||
@@ -51,9 +93,13 @@ class PolicyAcceptance {
|
||||
'id' => $this->id,
|
||||
'policy_version_id' => $this->policyVersionId,
|
||||
'student_id' => $this->studentId,
|
||||
'accepted_by' => $this->acceptorOrStudent(),
|
||||
'registration_type' => $this->registrationType,
|
||||
'registration_id' => $this->registrationId,
|
||||
'ip_address' => $this->ipAddress,
|
||||
'collected_via' => $this->collectedVia,
|
||||
'collected_note' => $this->collectedNote,
|
||||
'recorded_by' => $this->recordedBy,
|
||||
'accepted_at' => $this->acceptedAt,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,20 +19,35 @@ class PolicyController {
|
||||
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
$viewVersionId = 0;
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) {
|
||||
$this->handleFormAction();
|
||||
[ $notice, $viewVersionId ] = $this->handleFormAction();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
|
||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only policy/version selectors.
|
||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||
if ( 0 === $viewVersionId ) {
|
||||
$viewVersionId = absint( Val::int( $_GET['version_id'] ?? 0 ) );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$policyList = $this->policies->findAll();
|
||||
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
||||
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
|
||||
$viewedVersion = null !== $selectedPolicy ? $this->loadVersionForPolicy( (int) $selectedPolicy->id, $viewVersionId ) : null;
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/policies.php';
|
||||
}
|
||||
|
||||
private function handleFormAction(): void {
|
||||
/**
|
||||
* Process the posted action.
|
||||
*
|
||||
* @return array{string, int} Status notice, and the version to open in the
|
||||
* viewer (0 to leave the current selection alone).
|
||||
*/
|
||||
private function handleFormAction(): array {
|
||||
// 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'] ?? '' ) ) );
|
||||
@@ -47,16 +62,37 @@ 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 );
|
||||
}
|
||||
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
||||
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
if ( 'rename_policy' === $action ) {
|
||||
$title = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ) );
|
||||
|
||||
if ( '' === $title || mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$this->policies->updateTitle( $policyId, $title );
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %s: the policy's new title. */
|
||||
__( 'Policy renamed to "%s".', 'unsupervised-schedular' ),
|
||||
$title
|
||||
),
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
if ( 'add_version' === $action ) {
|
||||
@@ -64,6 +100,40 @@ class PolicyController {
|
||||
$this->service->addDraftVersion( $policyId, $body );
|
||||
}
|
||||
|
||||
if ( 'edit_version' === $action ) {
|
||||
$source = $this->loadVersionForPolicy( $policyId, absint( Val::int( $_POST['version_id'] ?? 0 ) ) );
|
||||
if ( null === $source ) {
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
|
||||
|
||||
// A draft has never been shown to a student, so it is edited in place.
|
||||
// A published (or archived) version is what students accepted, so an
|
||||
// edit branches a new draft and leaves the original untouched.
|
||||
if ( PolicyVersion::STATUS_DRAFT === $source->status ) {
|
||||
$this->versions->updateBody( (int) $source->id, $body );
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the edited version number. */
|
||||
__( 'Draft version %d was updated.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
(int) $source->id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the version number the edit was based on. */
|
||||
__( 'Your changes to version %d were saved as a new draft version.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
$this->service->addDraftVersion( $policyId, $body ),
|
||||
];
|
||||
}
|
||||
|
||||
if ( 'publish_version' === $action ) {
|
||||
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
||||
if ( $versionId > 0 ) {
|
||||
@@ -71,5 +141,20 @@ class PolicyController {
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a version by id, confirming it belongs to the given policy.
|
||||
*/
|
||||
private function loadVersionForPolicy( int $policyId, int $versionId ): ?PolicyVersion {
|
||||
if ( $versionId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $this->versions->findById( $versionId );
|
||||
|
||||
return null !== $version && $version->policyId === $policyId ? $version : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ class PolicyEndpoint {
|
||||
'policy_version_id' => $version->id,
|
||||
'version_number' => $version->versionNumber,
|
||||
// Bodies are kses'd on every write path, but the booking JS renders
|
||||
// this HTML raw — sanitise at output too so a missed write path can
|
||||
// never become stored XSS.
|
||||
'body' => wp_kses_post( (string) $version->body ),
|
||||
// this HTML raw — bodyHtml() sanitises at output too, so a missed
|
||||
// write path can never become stored XSS.
|
||||
'body' => $version->bodyHtml(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 ] );
|
||||
|
||||
@@ -46,6 +46,22 @@ class PolicyRepository {
|
||||
return array_map( Policy::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a policy. Only the title moves: the slug is the identifier the
|
||||
* booking and signup gates look policies up by, so renaming "Studio Policy"
|
||||
* to "Terms of Enrolment" must not quietly detach it from the versions
|
||||
* students have already accepted.
|
||||
*/
|
||||
public function updateTitle( int $policyId, string $title ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
[ 'title' => $title ],
|
||||
[ 'id' => $policyId ],
|
||||
[ '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
|
||||
public function updateCurrentVersion( int $policyId, int $versionId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -42,6 +42,19 @@ class PolicyVersion {
|
||||
return self::STATUS_PUBLISHED === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as display-ready HTML.
|
||||
*
|
||||
* Policy bodies are typed into a plain textarea, so most are written as
|
||||
* blank-line-separated prose with no markup at all — dropped into a page
|
||||
* as-is that collapses into one unreadable run of text. Running the same
|
||||
* `wpautop()` WordPress applies to post content turns those breaks into
|
||||
* paragraphs, and leaves bodies that do carry markup alone.
|
||||
*/
|
||||
public function bodyHtml(): string {
|
||||
return wpautop( wp_kses_post( (string) $this->body ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the version.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,15 @@ class Answer {
|
||||
public readonly int $registrationId,
|
||||
public readonly int $studentId,
|
||||
public readonly ?string $answerValue = null,
|
||||
/**
|
||||
* How this answer reached the studio when it did not come from the booking
|
||||
* form — see {@see IntakeProvenance}. Null is the ordinary case: the student
|
||||
* typed it in themselves.
|
||||
*/
|
||||
public readonly ?string $collectedVia = null,
|
||||
public readonly ?string $collectedNote = null,
|
||||
/** The staff member who typed it in, when somebody did. */
|
||||
public readonly int $recordedBy = 0,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -35,6 +44,9 @@ class Answer {
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
studentId: Val::int( $row->student_id ),
|
||||
answerValue: Val::stringOrNull( $row->answer_value ),
|
||||
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
|
||||
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
|
||||
recordedBy: Val::int( $row->recorded_by ?? 0 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -52,6 +64,9 @@ class Answer {
|
||||
'registration_id' => $this->registrationId,
|
||||
'student_id' => $this->studentId,
|
||||
'answer_value' => $this->answerValue,
|
||||
'collected_via' => $this->collectedVia,
|
||||
'collected_note' => $this->collectedNote,
|
||||
'recorded_by' => $this->recordedBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ class AnswerRepository {
|
||||
'registration_id' => $answer->registrationId,
|
||||
'student_id' => $answer->studentId,
|
||||
'answer_value' => $answer->answerValue,
|
||||
'collected_via' => $answer->collectedVia,
|
||||
'collected_note' => $answer->collectedNote,
|
||||
'recorded_by' => $answer->recordedBy,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%s', '%d', '%d', '%s', '%s' ]
|
||||
[ '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for one registration's audit trail: the intake answers
|
||||
* the student gave and the policy versions they accepted. Used by the admin
|
||||
* lesson detail view and the group-class enrolment detail view alike, mirroring
|
||||
* the per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*
|
||||
* Which rows belong to the registration is the subject's own business
|
||||
* ({@see IntakeSubject::intakeRegistrationId()}) — notably, a weekly lesson
|
||||
* series is answered for and agreed to once, against its anchor, so every
|
||||
* occurrence reads the same trail rather than only the first looking answered.
|
||||
*/
|
||||
class IntakeAudit {
|
||||
|
||||
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 registration, in submission
|
||||
* order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string, source: string}>
|
||||
*/
|
||||
public function answers( IntakeSubject $subject ): 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,
|
||||
'source' => $this->source( $answer->collectedVia, $answer->collectedNote, $answer->recordedBy ),
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a recorded row came from: given online by the student, or collected
|
||||
* some other way and typed in — in which case who typed it is named, since an
|
||||
* unattributed transcription is worth much less than an attributed one.
|
||||
*/
|
||||
private function source( ?string $collectedVia, ?string $collectedNote, int $recordedBy ): string {
|
||||
$described = IntakeProvenance::describe( $collectedVia, $collectedNote );
|
||||
|
||||
if ( null === $collectedVia || '' === $collectedVia || $recordedBy <= 0 ) {
|
||||
return $described;
|
||||
}
|
||||
|
||||
$user = get_userdata( $recordedBy );
|
||||
|
||||
return sprintf(
|
||||
/* translators: 1: how the answer was collected, 2: name of the staff member who recorded it. */
|
||||
__( '%1$s — recorded by %2$s', 'unsupervised-schedular' ),
|
||||
$described,
|
||||
UserName::format( $user instanceof \WP_User ? $user : null, $recordedBy )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted for this registration, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}>
|
||||
*/
|
||||
public function acceptances( IntakeSubject $subject ): 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 ?? '',
|
||||
'source' => $this->source( $acceptance->collectedVia, $acceptance->collectedNote, $acceptance->recordedBy ),
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user